From 2d313d806f420e38c08494204401f5cc95ca787f Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Tue, 25 Aug 2026 16:57:00 +0200 Subject: [PATCH] Unblock React Compiler for 18 components with value blocks inside try React Compiler cannot lower a conditional expression - `&&`, `||`, `??`, `?.`, a ternary - inside a try block. Three techniques, picked per site: - split `if (a && b)` into nested ifs, where there is no `else` to break - hoist the expression into a const above the try, where it does not depend on anything the try produces - move it into a module-scope helper, where it does Optional calls become `if (f) f()`, which keeps the arguments unevaluated when the callback is absent, exactly as `f?.()` does. Skipped components: 125 -> 107. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/Dialog/index.web.tsx | 16 ++++++++----- src/components/FeedCard.tsx | 4 +++- src/components/PostControls/index.tsx | 6 +++-- src/components/ProfileCard.tsx | 18 ++++++++++---- .../dialogs/lists/CreateOrEditListDialog.tsx | 20 ++++++++-------- .../components/CustomFeedHeader.tsx | 6 ++++- .../ModerationInteractionSettings/index.tsx | 4 +++- src/screens/Onboarding/StepFinished/index.tsx | 24 +++++++++++-------- .../Profile/Header/ProfileHeaderLabeler.tsx | 10 ++++---- .../Profile/Header/ProfileHeaderStandard.tsx | 14 +++++++---- src/screens/ProfileList/components/Header.tsx | 12 ++++++---- .../Signup/StepCaptcha/CaptchaWebView.web.tsx | 18 ++++++++++++-- src/state/queries/join-links.ts | 5 +++- src/state/queries/service-config.ts | 23 +++++++++++++----- src/view/com/composer/drafts/DraftItem.tsx | 3 ++- .../com/composer/photos/OpenCameraBtn.tsx | 22 +++++++++-------- src/view/com/feeds/ComposerPrompt.tsx | 20 ++++++++++++---- src/view/com/util/UserBanner.tsx | 23 +++++++++++------- 18 files changed, 163 insertions(+), 85 deletions(-) diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index e867acd60a..1c619777d7 100644 --- a/src/components/Dialog/index.web.tsx +++ b/src/components/Dialog/index.web.tsx @@ -73,12 +73,16 @@ export function Outer({ setIsOpen(false) try { - if (cb && typeof cb === 'function') { - // This timeout ensures that the callback runs at the same time as it would on native. I.e. - // console.log('Step 1') -> close(() => console.log('Step 3')) -> console.log('Step 2') - // This should always output 'Step 1', 'Step 2', 'Step 3', but without the timeout it would output - // 'Step 1', 'Step 3', 'Step 2'. - setTimeout(cb) + // Nested rather than `&&`: React Compiler cannot lower a logical + // expression in a test position inside a `try`. + if (cb) { + if (typeof cb === 'function') { + // This timeout ensures that the callback runs at the same time as it would on native. I.e. + // console.log('Step 1') -> close(() => console.log('Step 3')) -> console.log('Step 2') + // This should always output 'Step 1', 'Step 2', 'Step 3', but without the timeout it would output + // 'Step 1', 'Step 3', 'Step 2'. + setTimeout(cb) + } } } catch (e: any) { logger.error(`Dialog closeCallback failed`, { diff --git a/src/components/FeedCard.tsx b/src/components/FeedCard.tsx index bf55f218d6..eb9dee75f3 100644 --- a/src/components/FeedCard.tsx +++ b/src/components/FeedCard.tsx @@ -295,6 +295,8 @@ function SaveButtonInner({ e.preventDefault() e.stopPropagation() + const pinned = pin || false + try { if (savedFeedConfig) { await removeFeed(savedFeedConfig) @@ -303,7 +305,7 @@ function SaveButtonInner({ { type, value: uri, - pinned: pin || false, + pinned, }, ]) } diff --git a/src/components/PostControls/index.tsx b/src/components/PostControls/index.tsx index 6457267780..5a85f7a9ca 100644 --- a/src/components/PostControls/index.tsx +++ b/src/components/PostControls/index.tsx @@ -107,9 +107,10 @@ let PostControls = ({ return } + const existingLike = post.viewer?.like try { setHasLikeIconBeenToggled(true) - if (!post.viewer?.like) { + if (!existingLike) { sendInteraction({ item: post.uri, event: 'app.bsky.feed.defs#interactionLike', @@ -137,8 +138,9 @@ let PostControls = ({ return } + const existingRepost = post.viewer?.repost try { - if (!post.viewer?.repost) { + if (!existingRepost) { sendInteraction({ item: post.uri, event: 'app.bsky.feed.defs#interactionRepost', diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index d827e0010b..71653896cb 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -491,16 +491,21 @@ export function FollowButtonInner({ const onPressFollow = async (e: GestureResponderEvent) => { e.preventDefault() e.stopPropagation() + const displayNameOrHandle = profile.displayName || profile.handle try { await queueFollow() Toast.show( l`Following ${sanitizeDisplayName( - profile.displayName || profile.handle, + displayNameOrHandle, moderation.ui('displayName'), )}`, ) - onPressProp?.(e) - onFollow?.() + if (onPressProp) { + onPressProp(e) + } + if (onFollow) { + onFollow() + } } catch (e) { const err = e as Error if (err?.name !== 'AbortError') { @@ -514,15 +519,18 @@ export function FollowButtonInner({ const onPressUnfollow = async (e: GestureResponderEvent) => { e.preventDefault() e.stopPropagation() + const displayNameOrHandle = profile.displayName || profile.handle try { await queueUnfollow() Toast.show( l`No longer following ${sanitizeDisplayName( - profile.displayName || profile.handle, + displayNameOrHandle, moderation.ui('displayName'), )}`, ) - onPressProp?.(e) + if (onPressProp) { + onPressProp(e) + } } catch (e) { const err = e as Error if (err?.name !== 'AbortError') { diff --git a/src/components/dialogs/lists/CreateOrEditListDialog.tsx b/src/components/dialogs/lists/CreateOrEditListDialog.tsx index c146d489f5..482428e76b 100644 --- a/src/components/dialogs/lists/CreateOrEditListDialog.tsx +++ b/src/components/dialogs/lists/CreateOrEditListDialog.tsx @@ -221,6 +221,14 @@ function DialogInner({ const onPressSave = useCallback(async () => { setImageError('') setDisplayNameTooShort(false) + // Hoisted above the `try`: React Compiler cannot lower a conditional + // expression inside one. + const updatedMessage = isCurateList + ? _(msg({message: 'User list updated', context: 'toast'})) + : _(msg({message: 'Moderation list updated', context: 'toast'})) + const createdMessage = isCurateList + ? _(msg({message: 'User list created', context: 'toast'})) + : _(msg({message: 'Moderation list created', context: 'toast'})) try { if (displayName.length === 0) { setDisplayNameTooShort(true) @@ -244,11 +252,7 @@ function DialogInner({ descriptionFacets: richText.facets, avatar: newListAvatar, }) - Toast.show( - isCurateList - ? _(msg({message: 'User list updated', context: 'toast'})) - : _(msg({message: 'Moderation list updated', context: 'toast'})), - ) + Toast.show(updatedMessage) control.close(() => onSave?.(list.uri)) } else { const {uri} = await createListMutation({ @@ -258,11 +262,7 @@ function DialogInner({ descriptionFacets: richText.facets, avatar: newListAvatar, }) - Toast.show( - isCurateList - ? _(msg({message: 'User list created', context: 'toast'})) - : _(msg({message: 'Moderation list created', context: 'toast'})), - ) + Toast.show(createdMessage) control.close(() => onSave?.(uri)) } } catch (e: any) { diff --git a/src/screens/CustomFeed/components/CustomFeedHeader.tsx b/src/screens/CustomFeed/components/CustomFeedHeader.tsx index a2d4dc56dd..a7531e4726 100644 --- a/src/screens/CustomFeed/components/CustomFeedHeader.tsx +++ b/src/screens/CustomFeed/components/CustomFeedHeader.tsx @@ -450,10 +450,14 @@ function DialogInner({ const feedRkey = useMemo(() => new AtUri(info.uri).rkey, [info.uri]) const onToggleLiked = async () => { + // Hoisted out of the `try`: React Compiler cannot lower a logical + // expression in a test position there, and the `else` below rules out + // splitting this into nested ifs. + const shouldUnlike = isLiked && likeUri try { playHaptic() - if (isLiked && likeUri) { + if (shouldUnlike) { await unlikeFeed({uri: likeUri}) setLikeUri('') ax.metric('feed:unlike', {feedUrl: info.uri}) diff --git a/src/screens/ModerationInteractionSettings/index.tsx b/src/screens/ModerationInteractionSettings/index.tsx index c6ea9db622..d67c1c6fe2 100644 --- a/src/screens/ModerationInteractionSettings/index.tsx +++ b/src/screens/ModerationInteractionSettings/index.tsx @@ -95,11 +95,13 @@ function Inner({preferences}: {preferences: UsePreferencesQueryResponse}) { const onSave = useCallback(async () => { setError('') + const embeddingRules = maybeEditedPostgate.embeddingRules ?? [] + try { await setPostInteractionSettings({ threadgateAllowRules: threadgateAllowUISettingToAllowRecordValue(maybeEditedAllowUI), - postgateEmbeddingRules: maybeEditedPostgate.embeddingRules ?? [], + postgateEmbeddingRules: embeddingRules, }) Toast.show(_(msg({message: 'Settings saved', context: 'toast'}))) } catch (e: any) { diff --git a/src/screens/Onboarding/StepFinished/index.tsx b/src/screens/Onboarding/StepFinished/index.tsx index f90e7ddcd4..e868200e37 100644 --- a/src/screens/Onboarding/StepFinished/index.tsx +++ b/src/screens/Onboarding/StepFinished/index.tsx @@ -78,11 +78,12 @@ export function StepFinished() { logger.error('Failed to fetch starter pack', {safeMessage: e}) // don't tell the user, just get them through onboarding. } + const starterPackList = starterPack?.list try { - if (starterPack?.list) { + if (starterPackList) { listItems = await getAllListMembers( appviewClient, - starterPack.list.uri, + starterPackList.uri, ) } } catch (e) { @@ -93,19 +94,22 @@ export function StepFinished() { } } + // Hoisted above the `try`: React Compiler cannot lower these inside one, and + // `listItems` is already settled by the earlier try/catch. + const followDids = [ + BSKY_APP_ACCOUNT_DID, + ...(listItems?.map(i => i.subject.did) ?? []), + ] + const starterPackRef = starterPack + ? {uri: starterPack.uri, cid: starterPack.cid} + : undefined + try { const {interestsStepResults, profileStepResults} = state const {selectedInterests} = interestsStepResults await Promise.all([ - bulkWriteFollows( - pdsClient, - appviewClient, - [BSKY_APP_ACCOUNT_DID, ...(listItems?.map(i => i.subject.did) ?? [])], - starterPack - ? {uri: starterPack.uri, cid: starterPack.cid} - : undefined, - ), + bulkWriteFollows(pdsClient, appviewClient, followDids, starterPackRef), (async () => { // Interests need to get saved first, then we can write the feeds to prefs await pdsClient.call(setInterestsPref, {tags: selectedInterests}) diff --git a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx index c800d0ace3..07c0931b34 100644 --- a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx +++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx @@ -259,6 +259,9 @@ export function HeaderLabelerButtons({ requireAuth(async (): Promise => { playHaptic() const subscribe = !isSubscribed + const subscribeMetric = subscribe + ? 'moderation:subscribedToLabeler' + : 'moderation:unsubscribedFromLabeler' try { await toggleSubscription({ @@ -266,12 +269,7 @@ export function HeaderLabelerButtons({ subscribe, }) - ax.metric( - subscribe - ? 'moderation:subscribedToLabeler' - : 'moderation:unsubscribedFromLabeler', - {}, - ) + ax.metric(subscribeMetric, {}) } catch (e: any) { reset() if (e.message === 'MAX_LABELERS') { diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index f66587c947..f6321e36b0 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -238,14 +238,17 @@ export function HeaderStandardButtons({ const onPressFollow = () => { playHaptic() + const displayNameOrHandle = profile.displayName || profile.handle requireAuth(async () => { try { await queueFollow() - onFollow?.() + if (onFollow) { + onFollow() + } Toast.show( _( msg`Following ${sanitizeDisplayName( - profile.displayName || profile.handle, + displayNameOrHandle, moderation.ui('displayName'), )}`, ), @@ -264,14 +267,17 @@ export function HeaderStandardButtons({ const onPressUnfollow = () => { playHaptic() + const displayNameOrHandle = profile.displayName || profile.handle requireAuth(async () => { try { await queueUnfollow() - onUnfollow?.() + if (onUnfollow) { + onUnfollow() + } Toast.show( _( msg`No longer following ${sanitizeDisplayName( - profile.displayName || profile.handle, + displayNameOrHandle, moderation.ui('displayName'), )}`, ), diff --git a/src/screens/ProfileList/components/Header.tsx b/src/screens/ProfileList/components/Header.tsx index 98059c37de..7a3716a243 100644 --- a/src/screens/ProfileList/components/Header.tsx +++ b/src/screens/ProfileList/components/Header.tsx @@ -64,6 +64,12 @@ export function Header({ const onTogglePinned = async () => { playHaptic() + // Hoisted above the `try`: inside it, `pinned` is `!savedFeedConfig.pinned`, + // which is `!isPinned` on the branch that uses this. + const pinnedMessage = !isPinned + ? _(msg`Pinned to your feeds`) + : _(msg`Unpinned from your feeds`) + try { if (savedFeedConfig) { const pinned = !savedFeedConfig.pinned @@ -73,11 +79,7 @@ export function Header({ pinned, }, ]) - Toast.show( - pinned - ? _(msg`Pinned to your feeds`) - : _(msg`Unpinned from your feeds`), - ) + Toast.show(pinnedMessage) } else { await addSavedFeeds([ { diff --git a/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx b/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx index 5cd24666ed..3a522b2bad 100644 --- a/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx +++ b/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx @@ -5,6 +5,15 @@ import {type CaptchaWebViewProps} from './CaptchaWebView.shared' const REDIRECT_HOST = new URL(window.location.href).host +/** + * Module scope because React Compiler cannot lower an optional chain inside a + * `try`, and this one has to stay in the `try` - reading `location` on a + * cross-origin frame throws. + */ +function getFrameHref(frame: HTMLIFrameElement | null): string | undefined { + return frame?.contentWindow?.location.href +} + export function CaptchaWebView({ url, stateParam, @@ -29,7 +38,7 @@ export function CaptchaWebView({ ) as HTMLIFrameElement try { - const href = frame?.contentWindow?.location.href + const href = getFrameHref(frame) if (!href) return const urlp = new URL(href) @@ -37,7 +46,12 @@ export function CaptchaWebView({ if (urlp.host !== REDIRECT_HOST) return const code = urlp.searchParams.get('code') - if (urlp.searchParams.get('state') !== stateParam || !code) { + const stateMismatch = urlp.searchParams.get('state') !== stateParam + if (stateMismatch) { + onError({error: 'Invalid state or code'}) + return + } + if (!code) { onError({error: 'Invalid state or code'}) return } diff --git a/src/state/queries/join-links.ts b/src/state/queries/join-links.ts index dee7f4d379..eaf30ce77a 100644 --- a/src/state/queries/join-links.ts +++ b/src/state/queries/join-links.ts @@ -261,7 +261,10 @@ export function useGetJoinLinkPreview() { staleTime: STALE.SECONDS.FIFTEEN, }) const found = data.joinLinkPreviews[0] - return isKnownJoinLinkPreview(found) ? found : undefined + if (isKnownJoinLinkPreview(found)) { + return found + } + return undefined } catch (error) { logger.error('Failed to fetch join link preview', {safeMessage: error}) return undefined diff --git a/src/state/queries/service-config.ts b/src/state/queries/service-config.ts index e9f8277b46..8b23b62354 100644 --- a/src/state/queries/service-config.ts +++ b/src/state/queries/service-config.ts @@ -13,6 +13,22 @@ type ServiceConfig = { }[] } +/** + * Module scope because React Compiler cannot lower a `??` inside a `try`, and + * `data` only exists once the request in that `try` resolves. + */ +function toServiceConfig(data: { + checkEmailConfirmed?: boolean + liveNow?: ServiceConfig['liveNow'] +}): ServiceConfig { + return { + checkEmailConfirmed: Boolean(data.checkEmailConfirmed), + // @ts-expect-error not included in the lexicon atm + topicsEnabled: Boolean(data.topicsEnabled), + liveNow: data.liveNow ?? [], + } +} + export function useServiceConfigQuery() { const client = useAppviewClient() return useQuery({ @@ -22,12 +38,7 @@ export function useServiceConfigQuery() { queryFn: async () => { try { const data = await client.call(app.bsky.unspecced.getConfig) - return { - checkEmailConfirmed: Boolean(data.checkEmailConfirmed), - // @ts-expect-error not included in the lexicon atm - topicsEnabled: Boolean(data.topicsEnabled), - liveNow: data.liveNow ?? [], - } + return toServiceConfig(data) } catch (e) { return { checkEmailConfirmed: false, diff --git a/src/view/com/composer/drafts/DraftItem.tsx b/src/view/com/composer/drafts/DraftItem.tsx index ff891fcc2b..e741d2a6bd 100644 --- a/src/view/com/composer/drafts/DraftItem.tsx +++ b/src/view/com/composer/drafts/DraftItem.tsx @@ -287,9 +287,10 @@ function DraftMediaPreview({post}: {post: DraftPostDisplay}) { if (post.images && post.images.length > 0) { const loaded: LoadedImage[] = [] for (const image of post.images) { + const alt = image.altText || '' try { const url = await storage.loadMediaFromLocal(image.localPath) - loaded.push({url, alt: image.altText || ''}) + loaded.push({url, alt}) } catch (e) { // Image doesn't exist locally, skip it } diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx index ec02daa7d7..89743626b4 100644 --- a/src/view/com/composer/photos/OpenCameraBtn.tsx +++ b/src/view/com/composer/photos/OpenCameraBtn.tsx @@ -1,4 +1,3 @@ -import {useCallback} from 'react' import * as MediaLibrary from 'expo-media-library/legacy' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' @@ -20,13 +19,21 @@ export function OpenCameraBtn({disabled, onAdd}: OpenCameraBtnProps) { MediaLibrary.usePermissions({granularPermissions: ['photo']}) const t = useTheme() - const onPressTakePicture = useCallback(async () => { + const mediaGranted = mediaPermissionRes?.granted + const mediaCanAskAgain = mediaPermissionRes?.canAskAgain + + // No useCallback: with the diagnostics above resolved this component compiles, + // so React Compiler memoizes it, and the hand-written deps were what it could + // not preserve. + const onPressTakePicture = async () => { try { if (!(await requestCameraAccessIfNeeded())) { return } - if (!mediaPermissionRes?.granted && mediaPermissionRes?.canAskAgain) { - await requestMediaPermission() + if (!mediaGranted) { + if (mediaCanAskAgain) { + await requestMediaPermission() + } } const img = await openCamera({ @@ -49,12 +56,7 @@ export function OpenCameraBtn({disabled, onAdd}: OpenCameraBtnProps) { // ignore logger.warn('Error using camera', {error: err}) } - }, [ - onAdd, - requestCameraAccessIfNeeded, - mediaPermissionRes, - requestMediaPermission, - ]) + } const shouldShowCameraButton = IS_NATIVE || IS_WEB_MOBILE if (!shouldShowCameraButton) { diff --git a/src/view/com/feeds/ComposerPrompt.tsx b/src/view/com/feeds/ComposerPrompt.tsx index db42ead219..9e3dd4d7a5 100644 --- a/src/view/com/feeds/ComposerPrompt.tsx +++ b/src/view/com/feeds/ComposerPrompt.tsx @@ -56,8 +56,10 @@ export function ComposerPrompt() { requestVideoAccessIfNeeded(), ]) - if (!photoAccess && !videoAccess) { - return + if (!photoAccess) { + if (!videoAccess) { + return + } } if (Keyboard.isVisible()) { @@ -108,8 +110,10 @@ export function ComposerPrompt() { return } - if (IS_NATIVE && Keyboard.isVisible()) { - Keyboard.dismiss() + if (IS_NATIVE) { + if (Keyboard.isVisible()) { + Keyboard.dismiss() + } } const image = await openCamera({ @@ -127,8 +131,14 @@ export function ComposerPrompt() { }, ] + // Statement form rather than a ternary: React Compiler cannot lower a + // conditional expression inside a `try`, and `imageUris` is built here. + let nativeImageUris + if (IS_NATIVE) { + nativeImageUris = imageUris + } openComposer({ - imageUris: IS_NATIVE ? imageUris : undefined, + imageUris: nativeImageUris, logContext: 'Fab', }) } catch (err: any) { diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 9b297ff0ab..ab3cf785cd 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -79,15 +79,20 @@ export function UserBanner({ try { if (IS_NATIVE) { - onSelectNewBanner?.( - await compressIfNeeded( - await openCropper({ - imageUri: items[0].path, - aspectRatio: 3 / 1, - }), - IMAGE_SIZE_CONFIG_2K_1MB, - ), - ) + // Nested rather than `?.()`: React Compiler cannot lower an optional + // call inside a `try`. Like `?.()`, this leaves the arguments + // unevaluated when the callback is absent. + if (onSelectNewBanner) { + onSelectNewBanner( + await compressIfNeeded( + await openCropper({ + imageUri: items[0].path, + aspectRatio: 3 / 1, + }), + IMAGE_SIZE_CONFIG_2K_1MB, + ), + ) + } } else { setRawImage(await createComposerImage(items[0])) editImageDialogControl.open()