diff --git a/src/ageAssurance/useVerificationFlow.ts b/src/ageAssurance/useVerificationFlow.ts index d14aefd230..78e21727c8 100644 --- a/src/ageAssurance/useVerificationFlow.ts +++ b/src/ageAssurance/useVerificationFlow.ts @@ -2,6 +2,7 @@ import {useCallback, useState} from 'react' import type * as AgeRange from 'expo-age-range' import {useLingui} from '@lingui/react/macro' +import {withCleanup} from '#/lib/async/withCleanup' import {useSession} from '#/state/session' import {type DialogControlProps} from '#/components/Dialog' import * as Toast from '#/components/Toast' @@ -94,11 +95,14 @@ export function useAgeAssuranceVerificationFlow({ // Show a loading state while the OS age prompt is up. setIsVerifying(true) let signals: AgeRange.AgeRangeResponse | undefined - try { - signals = await getDeviceSignals() - } finally { - setIsVerifying(false) - } + await withCleanup( + async () => { + signals = await getDeviceSignals() + }, + () => { + setIsVerifying(false) + }, + ) if (signals) { const {assuredAge} = getAgeAssuranceDataFromDeviceSignals( region, diff --git a/src/components/dialogs/EmailDialog/components/ResendEmailText.tsx b/src/components/dialogs/EmailDialog/components/ResendEmailText.tsx index 300feeefae..318258583f 100644 --- a/src/components/dialogs/EmailDialog/components/ResendEmailText.tsx +++ b/src/components/dialogs/EmailDialog/components/ResendEmailText.tsx @@ -2,6 +2,7 @@ import {useState} from 'react' import {Trans, useLingui} from '@lingui/react/macro' import {wait} from '#/lib/async/wait' +import {withCleanup} from '#/lib/async/withCleanup' import {atoms as a, type TextStyleProp, useTheme} from '#/alf' import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {createStaticClick, InlineLinkText} from '#/components/Link' @@ -20,14 +21,17 @@ export function ResendEmailText({ const handleOnPress = async () => { setStatus('sending') - try { - await wait(1000, onPress()) - setStatus('success') - } finally { - setTimeout(() => { - setStatus(null) - }, 1000) - } + await withCleanup( + async () => { + await wait(1000, onPress()) + setStatus('success') + }, + () => { + setTimeout(() => { + setStatus(null) + }, 1000) + }, + ) } return ( diff --git a/src/lib/async/withCleanup.ts b/src/lib/async/withCleanup.ts new file mode 100644 index 0000000000..ce45a900ff --- /dev/null +++ b/src/lib/async/withCleanup.ts @@ -0,0 +1,25 @@ +/** + * Runs `fn`, then `cleanup`, whether or not `fn` throws - i.e. exactly what a + * `try`/`finally` does. + * + * It exists because React Compiler cannot lower a `finally` block, so any + * component or hook containing one is skipped entirely. Where the `try` has a + * `catch` that completes normally the cleanup can simply move below the + * `try`/`catch`, and that is the preferred fix. This is for the cases where it + * cannot: a `try`/`finally` with no `catch`, where the cleanup has to survive + * the throw path too. Keeping the `try`/`finally` here, out of the compiled + * function, preserves the semantics exactly. + * + * Note that `return` inside `fn` returns from `fn`, not from the caller. Only + * use this where the `try` is the whole body of its function. + */ +export async function withCleanup( + fn: () => Promise, + cleanup: () => void, +): Promise { + try { + return await fn() + } finally { + cleanup() + } +} diff --git a/src/lib/hooks/useToggleMutationQueue.ts b/src/lib/hooks/useToggleMutationQueue.ts index c4b86d3253..1f3359ce04 100644 --- a/src/lib/hooks/useToggleMutationQueue.ts +++ b/src/lib/hooks/useToggleMutationQueue.ts @@ -1,5 +1,7 @@ import {useCallback, useEffect, useRef, useState} from 'react' +import {withCleanup} from '#/lib/async/withCleanup' + type Task = { isOn: boolean resolve: (serverState: TServerState) => void @@ -45,31 +47,34 @@ export function useToggleMutationQueue({ // To avoid relying on the rendered state, capture it once at the start. // From that point on, and until the queue is drained, we'll use the real server state. let confirmedState: TServerState = initialState - try { - while (queue.queuedTask) { - const prevTask = queue.activeTask - const nextTask = queue.queuedTask - queue.activeTask = nextTask + await withCleanup( + async () => { + while (queue.queuedTask) { + const prevTask = queue.activeTask + const nextTask = queue.queuedTask + queue.activeTask = nextTask + queue.queuedTask = null + if (prevTask?.isOn === nextTask.isOn) { + // Skip multiple requests to update to the same value in a row. + prevTask.reject(new (AbortError as any)()) + continue + } + try { + // The state received from the server feeds into the next task. + // This lets us queue deletions of not-yet-created resources. + confirmedState = await runMutation(confirmedState, nextTask.isOn) + nextTask.resolve(confirmedState) + } catch (e) { + nextTask.reject(e) + } + } + }, + () => { + onSuccess(confirmedState) + queue.activeTask = null queue.queuedTask = null - if (prevTask?.isOn === nextTask.isOn) { - // Skip multiple requests to update to the same value in a row. - prevTask.reject(new (AbortError as any)()) - continue - } - try { - // The state received from the server feeds into the next task. - // This lets us queue deletions of not-yet-created resources. - confirmedState = await runMutation(confirmedState, nextTask.isOn) - nextTask.resolve(confirmedState) - } catch (e) { - nextTask.reject(e) - } - } - } finally { - onSuccess(confirmedState) - queue.activeTask = null - queue.queuedTask = null - } + }, + ) } function queueToggle(isOn: boolean): Promise { diff --git a/src/screens/Bookmarks.tsx b/src/screens/Bookmarks.tsx index c36cb0c0a5..ca6eab48b9 100644 --- a/src/screens/Bookmarks.tsx +++ b/src/screens/Bookmarks.tsx @@ -10,6 +10,7 @@ import { useNavigation, } from '@react-navigation/native' +import {withCleanup} from '#/lib/async/withCleanup' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' import { @@ -104,11 +105,14 @@ function BookmarksInner() { const onRefresh = useCallback(async () => { setIsPTRing(true) - try { - await refetch() - } finally { - setIsPTRing(false) - } + await withCleanup( + async () => { + await refetch() + }, + () => { + setIsPTRing(false) + }, + ) }, [refetch, setIsPTRing]) const onEndReached = useCallback(async () => { diff --git a/src/screens/Settings/InterestsSettings.tsx b/src/screens/Settings/InterestsSettings.tsx index 84589d7481..fb2ff95cbc 100644 --- a/src/screens/Settings/InterestsSettings.tsx +++ b/src/screens/Settings/InterestsSettings.tsx @@ -154,9 +154,8 @@ function Inner({ type: 'error', }, ) - } finally { - setIsSaving(false) } + setIsSaving(false) }, 1500) }, [_, pdsClient, setIsSaving, qc, preselectedInterests]) diff --git a/src/screens/Settings/components/DeleteAccountDialog.tsx b/src/screens/Settings/components/DeleteAccountDialog.tsx index 6c3d89bba1..68f302b4ba 100644 --- a/src/screens/Settings/components/DeleteAccountDialog.tsx +++ b/src/screens/Settings/components/DeleteAccountDialog.tsx @@ -106,17 +106,16 @@ function DeleteAccountDialogInner({ logger.error(raw || e, { message: 'Failed to send account deletion verification email', }) - } finally { - setEmailState(EmailState.DEFAULT) } + setEmailState(EmailState.DEFAULT) }, [client, cleanError, emailState, setEmailState]) const confirmDeletion = useCallback(async () => { + if (!currentAccount?.did) { + throw new Error('Invalid did') + } try { setError('') - if (!currentAccount?.did) { - throw new Error('Invalid did') - } const token = confirmCode.replace(WHITESPACE_RE, '') /* * Inform chat service of intent to delete account. A non-2xx response diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx index e905b29ac6..2db0cdeb23 100644 --- a/src/state/queries/notifications/unread.tsx +++ b/src/state/queries/notifications/unread.tsx @@ -15,6 +15,7 @@ import {type ISODatetimeString} from '@atproto/syntax' import {useQueryClient} from '@tanstack/react-query' import {EventEmitter} from 'eventemitter3' +import {withCleanup} from '#/lib/async/withCleanup' import BroadcastChannel from '#/lib/broadcast' import {resetBadgeCount} from '#/lib/notifications/notifications' import {useModerationOpts} from '#/state/preferences/moderation-opts' @@ -137,70 +138,75 @@ export function Provider({children}: React.PropsWithChildren<{}>) { invalidate, isPoll, }: {invalidate?: boolean; isPoll?: boolean} = {}) { - try { - if (!hasSession) return - if (AppState.currentState !== 'active') { - return - } - - // reduce polling if unread count is set - if (isPoll && cacheRef.current?.unreadCount !== 0) { - // if hit 30+ then don't poll, otherwise reduce polling by 50% - if (cacheRef.current?.unreadCount >= 30 || Math.random() >= 0.5) { + await withCleanup( + async () => { + if (!hasSession) return + if (AppState.currentState !== 'active') { return } - } - if (isFetchingRef.current) { - return - } - // Do not move this without ensuring it gets a symmetrical reset in the finally block. - isFetchingRef.current = true + // reduce polling if unread count is set + if (isPoll && cacheRef.current?.unreadCount !== 0) { + // if hit 30+ then don't poll, otherwise reduce polling by 50% + if (cacheRef.current?.unreadCount >= 30 || Math.random() >= 0.5) { + return + } + } - // count - const {page, indexedAt: lastIndexed} = await fetchPage({ - client, - cursor: undefined, - limit: 40, - queryClient, - moderationOpts, - reasons: [], + if (isFetchingRef.current) { + return + } + // Do not move this without ensuring it gets a symmetrical reset in the finally block. + isFetchingRef.current = true - // only fetch subjects when the page is going to be used - // in the notifications query, otherwise skip it - fetchAdditionalData: !!invalidate, - }) - const unreadCount = countUnread(page) - const unreadCountStr = - unreadCount >= 30 - ? '30+' - : unreadCount === 0 - ? '' - : String(unreadCount) + // count + const {page, indexedAt: lastIndexed} = await fetchPage({ + client, + cursor: undefined, + limit: 40, + queryClient, + moderationOpts, + reasons: [], - // track last sync - const now = new Date() - const lastIndexedDate = lastIndexed - ? new Date(lastIndexed) - : undefined - cacheRef.current = { - usableInFeed: !!invalidate, // will be used immediately - data: page, - syncedAt: - !lastIndexedDate || now > lastIndexedDate ? now : lastIndexedDate, - unreadCount, - } + // only fetch subjects when the page is going to be used + // in the notifications query, otherwise skip it + fetchAdditionalData: !!invalidate, + }) + const unreadCount = countUnread(page) + const unreadCountStr = + unreadCount >= 30 + ? '30+' + : unreadCount === 0 + ? '' + : String(unreadCount) - // update & broadcast - setNumUnread(unreadCountStr) - if (invalidate) { - truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all')) - truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions')) - } - broadcast.postMessage({event: unreadCountStr}) - } finally { - isFetchingRef.current = false - } + // track last sync + const now = new Date() + const lastIndexedDate = lastIndexed + ? new Date(lastIndexed) + : undefined + cacheRef.current = { + usableInFeed: !!invalidate, // will be used immediately + data: page, + syncedAt: + !lastIndexedDate || now > lastIndexedDate + ? now + : lastIndexedDate, + unreadCount, + } + + // update & broadcast + setNumUnread(unreadCountStr) + if (invalidate) { + truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all')) + truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions')) + } + broadcast.postMessage({event: unreadCountStr}) + }, + () => { + isFetchingRef.current = false + }, + ) }, getCachedUnreadPage() {