From e4e1987af9bd4ab7c5e9dede5de56fd5a6d00d76 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 31 Aug 2026 20:15:04 +0300 Subject: [PATCH] add age assurance outage screen --- .../components/DataUnavailableScreen.tsx | 41 +++++++++++++++++++ src/ageAssurance/data.tsx | 28 ++++++++++--- src/analytics/metrics/types.ts | 1 + src/components/Error.tsx | 24 +++++++++-- src/lib/async/retry.test.ts | 21 ++++++++++ src/lib/async/retry.ts | 36 +++++++++++++--- src/state/queries/preferences/index.ts | 3 +- src/view/shell/index.tsx | 5 ++- src/view/shell/index.web.tsx | 5 ++- 9 files changed, 145 insertions(+), 19 deletions(-) create mode 100644 src/ageAssurance/components/DataUnavailableScreen.tsx 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..6ed49aeea9 --- /dev/null +++ b/src/ageAssurance/components/DataUnavailableScreen.tsx @@ -0,0 +1,41 @@ +import {useState} from 'react' +import {useLingui} from '@lingui/react/macro' + +import {usePdsClient, useSessionApi} from '#/state/session' +import {Error} from '#/components/Error' +import {refetchOtherRequiredData} from '#/ageAssurance/data' +import {IS_WEB} from '#/env' + +export function DataUnavailableScreen() { + const {t: l} = useLingui() + const {logoutCurrentAccount} = useSessionApi() + const accountClient = usePdsClient() + const [isRetrying, setIsRetrying] = useState(false) + + const onRetry = async () => { + setIsRetrying(true) + try { + await refetchOtherRequiredData({accountClient}) + } catch { + // The error screen remains mounted so the user can retry again. + } finally { + setIsRetrying(false) + } + } + + return ( + { + if (IS_WEB) history.pushState(null, '', '/') + logoutCurrentAccount('AgeAssuranceDataUnavailableScreen') + }, + }} + /> + ) +} diff --git a/src/ageAssurance/data.tsx b/src/ageAssurance/data.tsx index f7d4476b66..67f7c2f31c 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 {networkRetry, requestRetry} from '#/lib/async/retry' import {createPersistedQueryStorage} from '#/lib/persisted-query-storage' import {getAge} from '#/lib/strings/time' import { @@ -359,7 +359,7 @@ async function getOtherRequiredData({ if (debug.enabled) return debug.resolve(debug.otherRequiredData) const did = accountClient.did const [prefs, actorDeclaration] = await Promise.all([ - accountClient.call(getPreferences), + requestRetry(3, () => accountClient.call(getPreferences)), fetchActorDeclarationRecord({did, client: accountClient}), ]) const data: OtherRequiredData = { @@ -456,10 +456,10 @@ export async function prefetchOtherRequiredData({ try { logger.debug(`prefetchOtherRequiredData: resolving...`) - const res = await networkRetry(3, () => - getOtherRequiredData({accountClient}), - ) - qc.setQueryData(qk, res) + await qc.fetchQuery({ + queryKey: qk, + queryFn: () => getOtherRequiredData({accountClient}), + }) } catch (err) { const e = err as Error logger.warn(`prefetchOtherRequiredData: failed`, { @@ -486,6 +486,20 @@ export function usePatchOtherRequiredData() { [currentAccount], ) } +export async function refetchOtherRequiredData({ + accountClient, +}: { + accountClient: Client +}) { + const did = accountClient.did + if (!did) return + const data = await getOtherRequiredData({accountClient}) + qc.setQueryData( + createOtherRequiredDataQueryKey({did}), + data, + ) + return data +} export function useOtherRequiredDataQuery() { const accountClient = usePdsClient() const did = accountClient.did @@ -497,6 +511,8 @@ export function useOtherRequiredDataQuery() { return getOtherRequiredDataFromCache({did}) }, queryKey: createOtherRequiredDataQueryKey({did: did!}), + retry: false, + retryOnMount: false, async queryFn() { return getOtherRequiredData({accountClient}) }, diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 78a4574f0f..81f1ccc5a2 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..9d5b7ebe3a 100644 --- a/src/components/Error.tsx +++ b/src/components/Error.tsx @@ -3,8 +3,9 @@ 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 * as Layout from '#/components/Layout' +import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' export function Error({ @@ -13,12 +14,20 @@ export function Error({ onRetry, onGoBack, hideBackButton, + secondaryAction, + isRetrying, }: { 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() @@ -55,21 +64,28 @@ export function Error({ color="primary" label={l`Press to retry`} onPress={onRetry} + disabled={isRetrying} size="large"> Retry + {isRetrying && } )} {!hideBackButton && ( )} diff --git a/src/lib/async/retry.test.ts b/src/lib/async/retry.test.ts new file mode 100644 index 0000000000..e5a3d36158 --- /dev/null +++ b/src/lib/async/retry.test.ts @@ -0,0 +1,21 @@ +import {exponentialBackoffRetryDelay, retry} from '#/lib/async/retry' + +describe('retry', () => { + it('calculates capped exponential backoff delays', () => { + expect([0, 1, 2, 3, 10].map(exponentialBackoffRetryDelay)).toEqual([ + 1000, 2000, 4000, 8000, 30_000, + ]) + }) + + it('applies the delay between attempts, but not after the last one', async () => { + const action = jest + .fn, []>() + .mockRejectedValueOnce(new TypeError('Failed to fetch')) + .mockRejectedValueOnce(new TypeError('Failed to fetch')) + .mockResolvedValue('ok') + const delay = jest.fn(() => 0) + + await expect(retry(3, () => true, action, delay)).resolves.toBe('ok') + expect(delay.mock.calls).toEqual([[0], [1]]) + }) +}) diff --git a/src/lib/async/retry.ts b/src/lib/async/retry.ts index 479a1cdc12..f748dd6646 100644 --- a/src/lib/async/retry.ts +++ b/src/lib/async/retry.ts @@ -1,23 +1,35 @@ import {timeout} from '#/lib/async/timeout' -import {isNetworkError} from '#/lib/strings/errors' +import {isNetworkError, shouldRetryError} from '#/lib/strings/errors' + +type RetryDelay = number | ((attempt: number) => number) + +export function exponentialBackoffRetryDelay(attempt: number) { + return Math.min(1000 * 2 ** attempt, 30_000) +} + +export function isRetryableRequestError(error: unknown) { + return isNetworkError(error) || shouldRetryError(error) +} export async function retry

( retries: number, shouldRetry: (err: any) => boolean, action: () => Promise

, - delay?: number, + delay?: RetryDelay, ): Promise

{ let lastErr + let attempt = 0 while (retries > 0) { try { return await action() } catch (e: any) { lastErr = e if (shouldRetry(e)) { - if (delay) { - await timeout(delay) - } retries-- + if (retries === 0) throw e + const delayMs = typeof delay === 'function' ? delay(attempt) : delay + if (delayMs) await timeout(delayMs) + attempt++ continue } throw e @@ -29,7 +41,19 @@ export async function retry

( export async function networkRetry

( retries: number, fn: () => Promise

, - delay?: number, + delay?: RetryDelay, ): Promise

{ return retry(retries, isNetworkError, fn, delay) } + +export async function requestRetry

( + retries: number, + fn: () => Promise

, +): Promise

{ + return retry( + retries, + isRetryableRequestError, + fn, + exponentialBackoffRetryDelay, + ) +} diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index d94342e40b..84ec1c7d13 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -24,6 +24,7 @@ import { import {type LabelPreference} from '@bsky/sdk/moderation' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' +import {requestRetry} from '#/lib/async/retry' import {PROD_DEFAULT_FEED} from '#/lib/constants' import {replaceEqualDeep} from '#/lib/functions' import {getAge} from '#/lib/strings/time' @@ -71,7 +72,7 @@ export function usePreferencesQuery() { if (!client.did) { return DEFAULT_LOGGED_OUT_PREFERENCES } else { - const res = await client.call(getPreferences) + const res = await requestRetry(3, () => client.call(getPreferences)) const labelerDids = res.moderationPrefs.labelers.map(l => l.did) 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 ? ( ) : (