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
+7 -3
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(
async () => {
signals = await getDeviceSignals() signals = await getDeviceSignals()
} finally { },
() => {
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(
async () => {
await wait(1000, onPress()) await wait(1000, onPress())
setStatus('success') setStatus('success')
} finally { },
() => {
setTimeout(() => { setTimeout(() => {
setStatus(null) setStatus(null)
}, 1000) }, 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()
}
}
+8 -3
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,7 +47,8 @@ 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(
async () => {
while (queue.queuedTask) { while (queue.queuedTask) {
const prevTask = queue.activeTask const prevTask = queue.activeTask
const nextTask = queue.queuedTask const nextTask = queue.queuedTask
@@ -65,11 +68,13 @@ export function useToggleMutationQueue<TServerState>({
nextTask.reject(e) nextTask.reject(e)
} }
} }
} finally { },
() => {
onSuccess(confirmedState) onSuccess(confirmedState)
queue.activeTask = null queue.activeTask = null
queue.queuedTask = null queue.queuedTask = null
} },
)
} }
function queueToggle(isOn: boolean): Promise<TServerState> { function queueToggle(isOn: boolean): Promise<TServerState> {
+7 -3
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(
async () => {
await refetch() await refetch()
} finally { },
() => {
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 () => {
try {
setError('')
if (!currentAccount?.did) { if (!currentAccount?.did) {
throw new Error('Invalid did') throw new Error('Invalid did')
} }
try {
setError('')
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
+10 -4
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,7 +138,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
invalidate, invalidate,
isPoll, isPoll,
}: {invalidate?: boolean; isPoll?: boolean} = {}) { }: {invalidate?: boolean; isPoll?: boolean} = {}) {
try { await withCleanup(
async () => {
if (!hasSession) return if (!hasSession) return
if (AppState.currentState !== 'active') { if (AppState.currentState !== 'active') {
return return
@@ -187,7 +189,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
usableInFeed: !!invalidate, // will be used immediately usableInFeed: !!invalidate, // will be used immediately
data: page, data: page,
syncedAt: syncedAt:
!lastIndexedDate || now > lastIndexedDate ? now : lastIndexedDate, !lastIndexedDate || now > lastIndexedDate
? now
: lastIndexedDate,
unreadCount, unreadCount,
} }
@@ -198,9 +202,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions')) truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions'))
} }
broadcast.postMessage({event: unreadCountStr}) broadcast.postMessage({event: unreadCountStr})
} finally { },
() => {
isFetchingRef.current = false isFetchingRef.current = false
} },
)
}, },
getCachedUnreadPage() { getCachedUnreadPage() {