Unblock React Compiler for 6 components with try/finally that cannot hoist

The earlier pass moved `finally` bodies below the try/catch, which is only
valid when a `catch` completes normally. These are the leftovers.

Where the catch does complete normally but a nested callback happened to
contain a `return`, the same hoist applies after all - InterestsSettings and
DeleteAccountDialog were false positives in the first pass's scan.

Where there is no `catch`, the cleanup has to survive the throw path, so the
try/finally moves into `withCleanup` at module scope instead. Same semantics,
outside the compiled function.

Skipped components: 125 -> 119.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tomek Zawadzki
2026-08-25 16:31:59 +02:00
parent f87fdd2ea2
commit dfa98d5946
8 changed files with 152 additions and 106 deletions
+9 -5
View File
@@ -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,
@@ -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 (
+25
View File
@@ -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<T>(
fn: () => Promise<T>,
cleanup: () => void,
): Promise<T> {
try {
return await fn()
} finally {
cleanup()
}
}
+29 -24
View File
@@ -1,5 +1,7 @@
import {useCallback, useEffect, useRef, useState} from 'react'
import {withCleanup} from '#/lib/async/withCleanup'
type Task<TServerState> = {
isOn: boolean
resolve: (serverState: TServerState) => void
@@ -45,31 +47,34 @@ export function useToggleMutationQueue<TServerState>({
// 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<TServerState> {
+9 -5
View File
@@ -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 () => {
+1 -2
View File
@@ -154,9 +154,8 @@ function Inner({
type: 'error',
},
)
} finally {
setIsSaving(false)
}
setIsSaving(false)
}, 1500)
}, [_, pdsClient, setIsSaving, qc, preselectedInterests])
@@ -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
+63 -57
View File
@@ -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() {