From 457b2be6804d4842fd82e16b4864297f2d973776 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 2 Sep 2026 21:35:14 +0300 Subject: [PATCH] APP-2997: Handle age assurance data load failures (#11612) Co-authored-by: Eric Bailey --- .../components/DataUnavailableScreen.tsx | 30 +++++++ src/ageAssurance/data.tsx | 41 +++++++-- src/ageAssurance/state.test.ts | 85 +++++++++++++++++++ src/ageAssurance/state.ts | 27 +++++- src/ageAssurance/types.ts | 3 +- src/analytics/metrics/types.ts | 1 + src/components/Error.tsx | 72 +++++++++++++--- src/lib/async/retry.test.ts | 8 ++ src/lib/async/retry.ts | 6 +- src/view/shell/index.tsx | 5 +- src/view/shell/index.web.tsx | 5 +- 11 files changed, 256 insertions(+), 27 deletions(-) create mode 100644 src/ageAssurance/components/DataUnavailableScreen.tsx create mode 100644 src/ageAssurance/state.test.ts create mode 100644 src/lib/async/retry.test.ts diff --git a/src/ageAssurance/components/DataUnavailableScreen.tsx b/src/ageAssurance/components/DataUnavailableScreen.tsx new file mode 100644 index 0000000000..cbc0efd2c8 --- /dev/null +++ b/src/ageAssurance/components/DataUnavailableScreen.tsx @@ -0,0 +1,30 @@ +import {useLingui} from '@lingui/react/macro' + +import {useSessionApi} from '#/state/session' +import {Error} from '#/components/Error' +import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSadIcon} from '#/components/icons/Emoji' +import {useOtherRequiredDataQuery} from '#/ageAssurance/data' +import {IS_WEB} from '#/env' + +export function DataUnavailableScreen() { + const {t: l} = useLingui() + const {logoutCurrentAccount} = useSessionApi() + const {isFetching, refetch} = useOtherRequiredDataQuery() + + return ( + void refetch()} + isRetrying={isFetching} + secondaryAction={{ + label: l`Sign out`, + onPress: () => { + if (IS_WEB) history.pushState(null, '', '/') + logoutCurrentAccount('AgeAssuranceDataUnavailableScreen') + }, + }} + /> + ) +} diff --git a/src/ageAssurance/data.tsx b/src/ageAssurance/data.tsx index 369b9eedd7..a2069ddc00 100644 --- a/src/ageAssurance/data.tsx +++ b/src/ageAssurance/data.tsx @@ -7,7 +7,7 @@ import {focusManager, QueryClient, useQuery} from '@tanstack/react-query' import {persistQueryClient} from '@tanstack/react-query-persist-client' import debounce from 'lodash.debounce' -import {networkRetry} from '#/lib/async/retry' +import {isRetryableRequestError, networkRetry} from '#/lib/async/retry' import {createPersistedQueryStorage} from '#/lib/persisted-query-storage' import {getAge} from '#/lib/strings/time' import { @@ -347,9 +347,15 @@ export type OtherRequiredData = { birthdate: string | undefined actorDeclaration?: chat.bsky.actor.declaration.Main } +export type OtherRequiredDataStatus = 'pending' | 'error' | 'success' +const otherRequiredDataRetryOptions = { + retry: (failureCount: number, error: unknown) => + failureCount < 2 && isRetryableRequestError(error), +} export function createOtherRequiredDataQueryKey({did}: {did: string}) { return ['otherRequiredData', did] } + async function getOtherRequiredData({ accountClient, }: { @@ -455,10 +461,11 @@ export async function prefetchOtherRequiredData({ try { logger.debug(`prefetchOtherRequiredData: resolving...`) - const res = await networkRetry(3, () => - getOtherRequiredData({accountClient}), - ) - qc.setQueryData(qk, res) + await qc.fetchQuery({ + ...otherRequiredDataRetryOptions, + queryKey: qk, + queryFn: () => getOtherRequiredData({accountClient}), + }) } catch (err) { const e = err as Error logger.warn(`prefetchOtherRequiredData: failed`, { @@ -490,12 +497,14 @@ export function useOtherRequiredDataQuery() { const did = accountClient.did return useQuery( { + ...otherRequiredDataRetryOptions, enabled: !!did, initialData: () => { if (!did) return return getOtherRequiredDataFromCache({did}) }, queryKey: createOtherRequiredDataQueryKey({did: did!}), + retryOnMount: false, async queryFn() { return getOtherRequiredData({accountClient}) }, @@ -722,6 +731,11 @@ export type AgeAssuranceServerData = { */ state: app.bsky.ageassurance.defs.State | undefined metadata: AgeAssuranceMetadata | undefined + /** + * Whether the account data needed to compute age assurance is available. + * A successful response without a birthdate is still `success`. + */ + otherRequiredDataStatus: OtherRequiredDataStatus /** * The native on-device age signals for the region the user is currently in, * if they've granted access there. Already resolved from the region-keyed @@ -739,6 +753,7 @@ const AgeAssuranceServerDataContext = createContext({ declaredAge: undefined, birthdate: undefined, }, + otherRequiredDataStatus: 'pending', deviceSignals: undefined, }) export function useAgeAssuranceServerDataContext() { @@ -752,7 +767,18 @@ export function AgeAssuranceServerDataProvider({ const {data: config} = useConfigQuery() const serverState = useServerStateQuery() const {state, metadata} = serverState.data || {} - const {data} = useOtherRequiredDataQuery() + const {data, errorUpdatedAt, status} = useOtherRequiredDataQuery() + /* + * A data-less query returns to `pending` and clears `error` while refetching, + * but retains `errorUpdatedAt`. Keep the error screen mounted until data + * loads successfully. + */ + const otherRequiredDataStatus: OtherRequiredDataStatus = + data !== undefined + ? 'success' + : status === 'error' || errorUpdatedAt > 0 + ? 'error' + : 'pending' // `select` resolves the cached region-keyed map to the current region. const {data: deviceSignals} = useDeviceSignalsQuery() const ctx = useMemo( @@ -767,9 +793,10 @@ export function AgeAssuranceServerDataProvider({ : undefined, birthdate: data?.birthdate, }, + otherRequiredDataStatus, deviceSignals, }), - [config, state, data, metadata, deviceSignals], + [config, state, data, metadata, otherRequiredDataStatus, deviceSignals], ) return ( diff --git a/src/ageAssurance/state.test.ts b/src/ageAssurance/state.test.ts new file mode 100644 index 0000000000..66b0fb644a --- /dev/null +++ b/src/ageAssurance/state.test.ts @@ -0,0 +1,85 @@ +import {computeAgeAssuranceState} from '#/ageAssurance/state' +import {AgeAssuranceAccess, AgeAssuranceStatus} from '#/ageAssurance/types' + +jest.mock('#/ageAssurance/data', () => ({})) +jest.mock('#/ageAssurance/logger', () => ({ + logger: { + debug: jest.fn(), + warn: jest.fn(), + }, +})) +jest.mock('#/state/session', () => ({})) + +const geolocation = { + countryCode: undefined, + regionCode: undefined, +} + +describe('computeAgeAssuranceState', () => { + it('computes access while required account data is pending', () => { + expect( + computeAgeAssuranceState({ + hasSession: true, + geolocation, + config: {regions: []}, + otherRequiredDataStatus: 'pending', + }), + ).toMatchObject({ + status: AgeAssuranceStatus.Unknown, + access: AgeAssuranceAccess.None, + }) + }) + + it('denies access when required account data fails', () => { + expect( + computeAgeAssuranceState({ + hasSession: true, + geolocation, + config: {regions: []}, + otherRequiredDataStatus: 'error', + }), + ).toEqual({ + status: AgeAssuranceStatus.Unknown, + access: AgeAssuranceAccess.None, + error: 'account-data', + }) + }) + + it('computes access after a successful response without a birthdate', () => { + expect( + computeAgeAssuranceState({ + hasSession: true, + geolocation, + config: {regions: []}, + metadata: {birthdate: undefined}, + otherRequiredDataStatus: 'success', + }), + ).toMatchObject({ + status: AgeAssuranceStatus.Unknown, + access: AgeAssuranceAccess.None, + }) + }) + + it('preserves authoritative terminal server state without account data', () => { + expect( + computeAgeAssuranceState({ + hasSession: true, + geolocation: {countryCode: 'AA', regionCode: undefined}, + config: { + regions: [ + { + countryCode: 'AA', + minAccessAge: 13, + rules: [], + }, + ], + }, + state: {status: 'blocked', access: 'none'}, + otherRequiredDataStatus: 'error', + }), + ).toMatchObject({ + status: AgeAssuranceStatus.Blocked, + access: AgeAssuranceAccess.None, + }) + }) +}) diff --git a/src/ageAssurance/state.ts b/src/ageAssurance/state.ts index fb799f934e..a054c0bc1f 100644 --- a/src/ageAssurance/state.ts +++ b/src/ageAssurance/state.ts @@ -9,6 +9,7 @@ import { getDeviceSignalsFromCacheForRegion, getOtherRequiredDataFromCache, getServerStateFromCache, + type OtherRequiredDataStatus, useAgeAssuranceServerDataContext, } from '#/ageAssurance/data' import {logger} from '#/ageAssurance/logger' @@ -35,12 +36,13 @@ import {device} from '#/storage' * server state before computing access based on AA config from the server + * geolocation and other data. */ -function computeAgeAssuranceState({ +export function computeAgeAssuranceState({ hasSession, geolocation, config, state, metadata, + otherRequiredDataStatus, deviceSignals, }: { hasSession: boolean @@ -48,6 +50,7 @@ function computeAgeAssuranceState({ config?: app.bsky.ageassurance.defs.Config state?: app.bsky.ageassurance.defs.State metadata?: AgeAssuranceMetadata + otherRequiredDataStatus: OtherRequiredDataStatus deviceSignals?: AgeRange.AgeRangeResponse }) { /** @@ -93,6 +96,14 @@ function computeAgeAssuranceState({ } } + if (otherRequiredDataStatus === 'error') { + return { + status: AgeAssuranceStatus.Unknown, + access: AgeAssuranceAccess.None, + error: 'account-data' as const, + } + } + /* * Otherwise, we need to compute the access based on the latest data. For * accounts with an accurate birthdate, our default fallback rules should @@ -177,6 +188,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) { geolocation, state: state.state, metadata, + otherRequiredDataStatus: 'success', deviceSignals, }) @@ -194,7 +206,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) { export function useAgeAssuranceState(): AgeAssuranceState { const {hasSession} = useSession() const geolocation = useGeolocation() - const {config, state, metadata, deviceSignals} = + const {config, state, metadata, otherRequiredDataStatus, deviceSignals} = useAgeAssuranceServerDataContext() return useMemo( @@ -205,9 +217,18 @@ export function useAgeAssuranceState(): AgeAssuranceState { geolocation, state, metadata, + otherRequiredDataStatus, deviceSignals, }), - [hasSession, geolocation, config, state, metadata, deviceSignals], + [ + hasSession, + geolocation, + config, + state, + metadata, + otherRequiredDataStatus, + deviceSignals, + ], ) } diff --git a/src/ageAssurance/types.ts b/src/ageAssurance/types.ts index b9b775396a..b2eba6ac73 100644 --- a/src/ageAssurance/types.ts +++ b/src/ageAssurance/types.ts @@ -43,7 +43,8 @@ export type AgeAssuranceState = { lastInitiatedAt?: string status: AgeAssuranceStatus access: AgeAssuranceAccess - error?: 'config' // maybe other specific cases in the future + isLoading?: boolean + error?: 'config' | 'account-data' } export type AgeAssuranceFlags = { diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 5aa3b00c5e..d3ebd65319 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -44,6 +44,7 @@ export type Events = { | 'SignupQueued' | 'Deactivated' | 'Takendown' + | 'AgeAssuranceDataUnavailableScreen' | 'AgeAssuranceNoAccessScreen' scope: 'current' | 'every' } diff --git a/src/components/Error.tsx b/src/components/Error.tsx index 190165a2df..a8739ce35a 100644 --- a/src/components/Error.tsx +++ b/src/components/Error.tsx @@ -3,27 +3,38 @@ import {Trans, useLingui} from '@lingui/react/macro' import {useGoBack} from '#/lib/hooks/useGoBack' import {atoms as a, useBreakpoints, useTheme} from '#/alf' -import {Button, ButtonText} from '#/components/Button' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {type Props as SVGIconProps} from '#/components/icons/common' import * as Layout from '#/components/Layout' +import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' export function Error({ + icon: Icon, title, message, onRetry, onGoBack, hideBackButton, + secondaryAction, + isRetrying, }: { + icon?: React.ComponentType title?: string message?: string onRetry?: () => unknown onGoBack?: () => unknown hideBackButton?: boolean + isRetrying?: boolean + secondaryAction?: { + label: string + accessibilityLabel?: string + onPress: () => unknown + } }) { const {t: l} = useLingui() const t = useTheme() const {gtMobile} = useBreakpoints() - const goBack = useGoBack(onGoBack) return ( - - {title} + + {Icon && } + + {title} + {onRetry && ( )} - {!hideBackButton && ( + {!hideBackButton && secondaryAction ? ( - )} + ) : !hideBackButton ? ( + + ) : null} ) } + +function GoBackButton({ + hasRetry, + isRetrying, + onGoBack, +}: { + hasRetry: boolean + isRetrying?: boolean + onGoBack?: () => unknown +}) { + const {t: l} = useLingui() + const goBack = useGoBack(onGoBack) + + return ( + + ) +} diff --git a/src/lib/async/retry.test.ts b/src/lib/async/retry.test.ts new file mode 100644 index 0000000000..97a321ec8c --- /dev/null +++ b/src/lib/async/retry.test.ts @@ -0,0 +1,8 @@ +import {isRetryableRequestError} from '#/lib/async/retry' + +describe('retry', () => { + it('identifies retryable request errors', () => { + expect(isRetryableRequestError(new TypeError('Failed to fetch'))).toBe(true) + expect(isRetryableRequestError(new Error('Invalid request'))).toBe(false) + }) +}) diff --git a/src/lib/async/retry.ts b/src/lib/async/retry.ts index 479a1cdc12..cb706c61c9 100644 --- a/src/lib/async/retry.ts +++ b/src/lib/async/retry.ts @@ -1,5 +1,9 @@ import {timeout} from '#/lib/async/timeout' -import {isNetworkError} from '#/lib/strings/errors' +import {isNetworkError, shouldRetryError} from '#/lib/strings/errors' + +export function isRetryableRequestError(error: unknown) { + return isNetworkError(error) || shouldRetryError(error) +} export async function retry

( retries: number, diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 9603236d4d..c74a055499 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -40,6 +40,7 @@ import { } from '#/components/PolicyUpdateOverlay' import {Outlet as PortalOutlet} from '#/components/Portal' import {useAgeAssurance} from '#/ageAssurance' +import {DataUnavailableScreen} from '#/ageAssurance/components/DataUnavailableScreen' import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen' import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay' import {PassiveAnalytics} from '#/analytics/PassiveAnalytics' @@ -245,7 +246,9 @@ export function Shell() { ) : ( <> - {aa.state.access === aa.Access.None ? ( + {aa.state.error === 'account-data' ? ( + + ) : aa.state.access === aa.Access.None ? ( ) : ( diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 6a68530f05..325e665761 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -30,6 +30,7 @@ import { import {Outlet as PortalOutlet} from '#/components/Portal' import {WelcomeModal} from '#/components/WelcomeModal' import {useAgeAssurance} from '#/ageAssurance' +import {DataUnavailableScreen} from '#/ageAssurance/components/DataUnavailableScreen' import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen' import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay' import {PassiveAnalytics} from '#/analytics/PassiveAnalytics' @@ -167,7 +168,9 @@ export function Shell() { ) : ( <> - {aa.state.access === aa.Access.None ? ( + {aa.state.error === 'account-data' ? ( + + ) : aa.state.access === aa.Access.None ? ( ) : (