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 type * as AgeRange from 'expo-age-range'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
import {withCleanup} from '#/lib/async/withCleanup'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {type DialogControlProps} from '#/components/Dialog' import {type DialogControlProps} from '#/components/Dialog'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
@@ -94,11 +95,14 @@ export function useAgeAssuranceVerificationFlow({
// Show a loading state while the OS age prompt is up. // Show a loading state while the OS age prompt is up.
setIsVerifying(true) setIsVerifying(true)
let signals: AgeRange.AgeRangeResponse | undefined let signals: AgeRange.AgeRangeResponse | undefined
try { await withCleanup(
signals = await getDeviceSignals() async () => {
} finally { signals = await getDeviceSignals()
setIsVerifying(false) },
} () => {
setIsVerifying(false)
},
)
if (signals) { if (signals) {
const {assuredAge} = getAgeAssuranceDataFromDeviceSignals( const {assuredAge} = getAgeAssuranceDataFromDeviceSignals(
region, region,
@@ -2,6 +2,7 @@ import {useState} from 'react'
import {Trans, useLingui} from '@lingui/react/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {wait} from '#/lib/async/wait' import {wait} from '#/lib/async/wait'
import {withCleanup} from '#/lib/async/withCleanup'
import {atoms as a, type TextStyleProp, useTheme} from '#/alf' import {atoms as a, type TextStyleProp, useTheme} from '#/alf'
import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {createStaticClick, InlineLinkText} from '#/components/Link' import {createStaticClick, InlineLinkText} from '#/components/Link'
@@ -20,14 +21,17 @@ export function ResendEmailText({
const handleOnPress = async () => { const handleOnPress = async () => {
setStatus('sending') setStatus('sending')
try { await withCleanup(
await wait(1000, onPress()) async () => {
setStatus('success') await wait(1000, onPress())
} finally { setStatus('success')
setTimeout(() => { },
setStatus(null) () => {
}, 1000) setTimeout(() => {
} setStatus(null)
}, 1000)
},
)
} }
return ( 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 {useCallback, useEffect, useRef, useState} from 'react'
import {withCleanup} from '#/lib/async/withCleanup'
type Task<TServerState> = { type Task<TServerState> = {
isOn: boolean isOn: boolean
resolve: (serverState: TServerState) => void 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. // 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. // From that point on, and until the queue is drained, we'll use the real server state.
let confirmedState: TServerState = initialState let confirmedState: TServerState = initialState
try { await withCleanup(
while (queue.queuedTask) { async () => {
const prevTask = queue.activeTask while (queue.queuedTask) {
const nextTask = queue.queuedTask const prevTask = queue.activeTask
queue.activeTask = nextTask 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 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> { function queueToggle(isOn: boolean): Promise<TServerState> {
+9 -5
View File
@@ -10,6 +10,7 @@ import {
useNavigation, useNavigation,
} from '@react-navigation/native' } from '@react-navigation/native'
import {withCleanup} from '#/lib/async/withCleanup'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking'
import { import {
@@ -104,11 +105,14 @@ function BookmarksInner() {
const onRefresh = useCallback(async () => { const onRefresh = useCallback(async () => {
setIsPTRing(true) setIsPTRing(true)
try { await withCleanup(
await refetch() async () => {
} finally { await refetch()
setIsPTRing(false) },
} () => {
setIsPTRing(false)
},
)
}, [refetch, setIsPTRing]) }, [refetch, setIsPTRing])
const onEndReached = useCallback(async () => { const onEndReached = useCallback(async () => {
+1 -2
View File
@@ -154,9 +154,8 @@ function Inner({
type: 'error', type: 'error',
}, },
) )
} finally {
setIsSaving(false)
} }
setIsSaving(false)
}, 1500) }, 1500)
}, [_, pdsClient, setIsSaving, qc, preselectedInterests]) }, [_, pdsClient, setIsSaving, qc, preselectedInterests])
@@ -106,17 +106,16 @@ function DeleteAccountDialogInner({
logger.error(raw || e, { logger.error(raw || e, {
message: 'Failed to send account deletion verification email', message: 'Failed to send account deletion verification email',
}) })
} finally {
setEmailState(EmailState.DEFAULT)
} }
setEmailState(EmailState.DEFAULT)
}, [client, cleanError, emailState, setEmailState]) }, [client, cleanError, emailState, setEmailState])
const confirmDeletion = useCallback(async () => { const confirmDeletion = useCallback(async () => {
if (!currentAccount?.did) {
throw new Error('Invalid did')
}
try { try {
setError('') setError('')
if (!currentAccount?.did) {
throw new Error('Invalid did')
}
const token = confirmCode.replace(WHITESPACE_RE, '') const token = confirmCode.replace(WHITESPACE_RE, '')
/* /*
* Inform chat service of intent to delete account. A non-2xx response * 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 {useQueryClient} from '@tanstack/react-query'
import {EventEmitter} from 'eventemitter3' import {EventEmitter} from 'eventemitter3'
import {withCleanup} from '#/lib/async/withCleanup'
import BroadcastChannel from '#/lib/broadcast' import BroadcastChannel from '#/lib/broadcast'
import {resetBadgeCount} from '#/lib/notifications/notifications' import {resetBadgeCount} from '#/lib/notifications/notifications'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -137,70 +138,75 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
invalidate, invalidate,
isPoll, isPoll,
}: {invalidate?: boolean; isPoll?: boolean} = {}) { }: {invalidate?: boolean; isPoll?: boolean} = {}) {
try { await withCleanup(
if (!hasSession) return async () => {
if (AppState.currentState !== 'active') { if (!hasSession) return
return if (AppState.currentState !== 'active') {
}
// 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 return
} }
}
if (isFetchingRef.current) { // reduce polling if unread count is set
return if (isPoll && cacheRef.current?.unreadCount !== 0) {
} // if hit 30+ then don't poll, otherwise reduce polling by 50%
// Do not move this without ensuring it gets a symmetrical reset in the finally block. if (cacheRef.current?.unreadCount >= 30 || Math.random() >= 0.5) {
isFetchingRef.current = true return
}
}
// count if (isFetchingRef.current) {
const {page, indexedAt: lastIndexed} = await fetchPage({ return
client, }
cursor: undefined, // Do not move this without ensuring it gets a symmetrical reset in the finally block.
limit: 40, isFetchingRef.current = true
queryClient,
moderationOpts,
reasons: [],
// only fetch subjects when the page is going to be used // count
// in the notifications query, otherwise skip it const {page, indexedAt: lastIndexed} = await fetchPage({
fetchAdditionalData: !!invalidate, client,
}) cursor: undefined,
const unreadCount = countUnread(page) limit: 40,
const unreadCountStr = queryClient,
unreadCount >= 30 moderationOpts,
? '30+' reasons: [],
: unreadCount === 0
? ''
: String(unreadCount)
// track last sync // only fetch subjects when the page is going to be used
const now = new Date() // in the notifications query, otherwise skip it
const lastIndexedDate = lastIndexed fetchAdditionalData: !!invalidate,
? new Date(lastIndexed) })
: undefined const unreadCount = countUnread(page)
cacheRef.current = { const unreadCountStr =
usableInFeed: !!invalidate, // will be used immediately unreadCount >= 30
data: page, ? '30+'
syncedAt: : unreadCount === 0
!lastIndexedDate || now > lastIndexedDate ? now : lastIndexedDate, ? ''
unreadCount, : String(unreadCount)
}
// update & broadcast // track last sync
setNumUnread(unreadCountStr) const now = new Date()
if (invalidate) { const lastIndexedDate = lastIndexed
truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all')) ? new Date(lastIndexed)
truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions')) : undefined
} cacheRef.current = {
broadcast.postMessage({event: unreadCountStr}) usableInFeed: !!invalidate, // will be used immediately
} finally { data: page,
isFetchingRef.current = false 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() { getCachedUnreadPage() {