From 20f5c459d6776c824e763a329e6335d3ae309f7a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 20:18:21 +0000 Subject: [PATCH] Stop the apply-ota intent link from re-firing after a reload `reloadAsync()` restarts the JS runtime but not the native process, and `expo-linking` keeps handing out the URL the app was opened with (its native registry outlives the runtime). The `previousIntentUrl` guard lives in the JS runtime, so it was wiped by the reload and the launch URL was handled a second time: `intent/apply-ota` prompted to apply the deployment again, which reloaded again, and looped. Reloads now go through `reloadWithUpdate`, which leaves a marker in device storage, and the intent handler seeds its guard with the launch URL when it sees that marker, so the URL is treated as already handled. Also fix the pull request deployment detection that made this a loop rather than a stray prompt. `currentlyRunning.channel` is the channel configured in the native build (`production`/`testflight`), not the channel an update was served from - our updates service selects that with the `channel` extra param - so it never started with `pull-request` and the back-off guards in `useOTAUpdates` were dead. On a TestFlight build that meant the automatic check reset the channel param and offered to relaunch onto the regular bundle, which re-fired the intent, which re-applied the deployment. Instead, record the update id we apply and compare it against the running one; reverting drops the record so the automatic checks resume. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DEz4XBc47YabrrCzSfcRXj --- src/lib/hooks/__tests__/useOTAUpdates.test.ts | 60 ++++++++++ src/lib/hooks/useIntentHandler.ts | 21 +++- src/lib/hooks/useOTAUpdates.ts | 106 ++++++++++++++++-- src/lib/hooks/useOTAUpdates.web.ts | 6 + src/screens/Settings/components/OTAInfo.tsx | 3 +- src/storage/schema.ts | 16 +++ 6 files changed, 197 insertions(+), 15 deletions(-) create mode 100644 src/lib/hooks/__tests__/useOTAUpdates.test.ts diff --git a/src/lib/hooks/__tests__/useOTAUpdates.test.ts b/src/lib/hooks/__tests__/useOTAUpdates.test.ts new file mode 100644 index 0000000000..a6ad40fb06 --- /dev/null +++ b/src/lib/hooks/__tests__/useOTAUpdates.test.ts @@ -0,0 +1,60 @@ +import {beforeEach, describe, expect, jest, test} from '@jest/globals' + +import { + consumeOTAReloadMarker, + reloadWithUpdate, +} from '#/lib/hooks/useOTAUpdates' +import {device} from '#/storage' + +jest.mock('react-native-mmkv', () => ({ + MMKV: class MMKVMock { + _store = new Map() + + set(key: string, value: unknown) { + this._store.set(key, value) + } + + getString(key: string) { + return this._store.get(key) + } + + delete(key: string) { + return this._store.delete(key) + } + }, +})) + +jest.mock('expo-updates', () => ({ + checkForUpdateAsync: jest.fn(), + fetchUpdateAsync: jest.fn(), + isEnabled: true, + reloadAsync: jest.fn(), + setExtraParamAsync: jest.fn(), + useUpdates: jest.fn(), +})) + +describe('consumeOTAReloadMarker', () => { + beforeEach(() => { + device.remove(['otaReloadedAt']) + }) + + test('is false when the app was not reloaded by us', () => { + expect(consumeOTAReloadMarker()).toBe(false) + }) + + test('is true in the runtime that follows a reload', async () => { + await reloadWithUpdate() + expect(consumeOTAReloadMarker()).toBe(true) + }) + + test('only the first caller in a runtime sees the marker', async () => { + await reloadWithUpdate() + expect(consumeOTAReloadMarker()).toBe(true) + expect(consumeOTAReloadMarker()).toBe(false) + }) + + test('ignores a marker left behind by a reload that never happened', () => { + device.set(['otaReloadedAt'], Date.now() - 5 * 60e3) + expect(consumeOTAReloadMarker()).toBe(false) + }) +}) diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts index eaa6482830..7513876b25 100644 --- a/src/lib/hooks/useIntentHandler.ts +++ b/src/lib/hooks/useIntentHandler.ts @@ -15,14 +15,29 @@ import {useIntentDialogs} from '#/components/intents/IntentDialogs' import {useAnalytics} from '#/analytics' import {IS_IOS, IS_NATIVE} from '#/env' import {Referrer} from '../../../modules/expo-bluesky-swiss-army' -import {useApplyPullRequestOTAUpdate} from './useOTAUpdates' +import { + consumeOTAReloadMarker, + useApplyPullRequestOTAUpdate, +} from './useOTAUpdates' type IntentType = 'compose' | 'verify-email' | 'age-assurance' | 'apply-ota' const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/ -// This needs to stay outside of react to persist between account switches -let previousIntentUrl = '' +/* + * This needs to stay outside of react to persist between account switches. + * + * Reloading the app to apply an OTA update restarts the JS runtime but not the + * native process, and `expo-linking` keeps reporting the URL the app was opened + * with, so the launch URL is handed to us again in the new runtime. Left alone, + * an `intent/apply-ota` link would fire again on the reload it just caused - + * prompting to apply the deployment, reloading, and looping. So when the runtime + * we're starting in is one we reloaded into, seed the guard with the launch URL + * to mark it as already handled. + */ +let previousIntentUrl = consumeOTAReloadMarker() + ? (Linking.getLinkingURL() ?? '') + : '' export function useIntentHandler() { const incomingUrl = Linking.useLinkingURL() diff --git a/src/lib/hooks/useOTAUpdates.ts b/src/lib/hooks/useOTAUpdates.ts index 766fb959df..922ad3dd83 100644 --- a/src/lib/hooks/useOTAUpdates.ts +++ b/src/lib/hooks/useOTAUpdates.ts @@ -13,9 +13,66 @@ import { import {isNetworkError} from '#/lib/strings/errors' import {logger} from '#/logger' import {IS_ANDROID, IS_IOS, IS_TESTFLIGHT} from '#/env' +import {device} from '#/storage' const MINIMUM_MINIMIZE_TIME = 15 * 60e3 +/* + * A reload brings up a new JS runtime within a few seconds, so a marker older + * than this was left behind by a reload that never happened and is ignored. + */ +const RELOAD_MARKER_MAX_AGE = 60e3 + +/** + * Reload the app to launch a downloaded update. Always use this instead of + * `reloadAsync` so that `consumeOTAReloadMarker` can tell that the next JS + * runtime came from a reload. + */ +export async function reloadWithUpdate() { + device.set(['otaReloadedAt'], Date.now()) + await reloadAsync() +} + +/** + * Whether this JS runtime was started by us reloading the app to apply an + * update, rather than by the user opening the app. + * + * `reloadAsync` restarts the JS runtime but not the native process, and + * `expo-linking` keeps handing out the URL the app was originally opened with + * (its native registry outlives the runtime), so anything that acts on that URL + * needs to know to sit out the first pass after a reload. Clears the marker, so + * only the first caller within a runtime gets `true`. + */ +export function consumeOTAReloadMarker() { + const reloadedAt = device.get(['otaReloadedAt']) + if (reloadedAt === undefined) return false + device.remove(['otaReloadedAt']) + return Date.now() - reloadedAt < RELOAD_MARKER_MAX_AGE +} + +/** + * The pull request deployment channel the running update was served from, or + * `undefined` if we're not running a pull request deployment. + * + * `useUpdates().currentlyRunning.channel` is the channel configured in the + * native build (`production` or `testflight`), not the channel the running + * update was served from - our updates service picks that up from the `channel` + * extra param - so it can never name a pull request deployment. Instead we + * compare the running update against the one `tryApplyUpdate` recorded. + */ +function getRunningPullRequestChannel(runningUpdateId: string | undefined) { + const applied = device.get(['appliedOTADeployment']) + if (!applied || !runningUpdateId) return undefined + /* + * expo-updates lowercases the running update id (iOS reports uppercase + * UUIDs), while the manifest id we recorded is whatever the service sent. + */ + if (applied.updateId.toLowerCase() !== runningUpdateId.toLowerCase()) { + return undefined + } + return applied.channel +} + async function setExtraParams() { await setExtraParamAsync( IS_IOS ? 'ios-build-number' : 'android-build-number', @@ -57,7 +114,7 @@ async function updateTestflight() { text: 'Relaunch', style: 'default', onPress: async () => { - await reloadAsync() + await reloadWithUpdate() }, }, ], @@ -68,9 +125,12 @@ async function updateTestflight() { export function useApplyPullRequestOTAUpdate() { const {currentlyRunning} = useUpdates() const [pending, setPending] = useState(false) - const currentChannel = currentlyRunning?.channel + const pullRequestChannel = getRunningPullRequestChannel( + currentlyRunning?.updateId, + ) const isCurrentlyRunningPullRequestDeployment = - currentChannel?.startsWith('pull-request') + pullRequestChannel !== undefined + const currentChannel = pullRequestChannel ?? currentlyRunning?.channel const tryApplyUpdate = async (channel: string) => { setPending(true) @@ -89,8 +149,19 @@ export function useApplyPullRequestOTAUpdate() { text: 'Relaunch', style: 'default', onPress: async () => { - await fetchUpdateAsync() - await reloadAsync() + const fetched = await fetchUpdateAsync() + /* + * Record what we're about to launch so we can recognize the + * deployment as running once it comes up, see + * `getRunningPullRequestChannel`. + */ + if (fetched.isNew) { + device.set(['appliedOTADeployment'], { + channel, + updateId: fetched.manifest.id, + }) + } + await reloadWithUpdate() }, }, ], @@ -105,6 +176,13 @@ export function useApplyPullRequestOTAUpdate() { } const revertToEmbedded = async () => { + /* + * Drop the record before reverting: `updateTestflight` can only relaunch us + * if a newer regular update happens to be available, and as long as we + * consider a pull request deployment to be running the automatic checks stay + * backed off - which would leave no way off the deployment at all. + */ + device.remove(['appliedOTADeployment']) try { await updateTestflight() } catch (e: any) { @@ -129,7 +207,8 @@ export function useOTAUpdates() { const ranInitialCheck = useRef(false) const timeout = useRef(undefined) const {currentlyRunning, isUpdatePending} = useUpdates() - const currentChannel = currentlyRunning?.channel + const isRunningPullRequestDeployment = + getRunningPullRequestChannel(currentlyRunning?.updateId) !== undefined const setCheckTimeout = useCallback(() => { timeout.current = setTimeout(async () => { @@ -165,7 +244,7 @@ export function useOTAUpdates() { useEffect(() => { // We don't need to check anything if the current update is a PR update - if (currentChannel?.startsWith('pull-request')) { + if (isRunningPullRequestDeployment) { return } @@ -182,13 +261,18 @@ export function useOTAUpdates() { setCheckTimeout() ranInitialCheck.current = true - }, [onIsTestFlight, currentChannel, setCheckTimeout, shouldReceiveUpdates]) + }, [ + onIsTestFlight, + isRunningPullRequestDeployment, + setCheckTimeout, + shouldReceiveUpdates, + ]) // After the app has been minimized for 15 minutes, we want to either A. install an update if one has become available // or B check for an update again. useEffect(() => { // We also don't start this timeout if the user is on a pull request update - if (!isEnabled || currentChannel?.startsWith('pull-request')) { + if (!isEnabled || isRunningPullRequestDeployment) { return } @@ -210,7 +294,7 @@ export function useOTAUpdates() { // chances are that there isn't anything important going on in the current session. if (lastMinimize.current <= Date.now() - MINIMUM_MINIMIZE_TIME) { if (isUpdatePending) { - await reloadAsync() + await reloadWithUpdate() } else { setCheckTimeout() } @@ -227,5 +311,5 @@ export function useOTAUpdates() { clearTimeout(timeout.current) subscription.remove() } - }, [isUpdatePending, currentChannel, setCheckTimeout]) + }, [isUpdatePending, isRunningPullRequestDeployment, setCheckTimeout]) } diff --git a/src/lib/hooks/useOTAUpdates.web.ts b/src/lib/hooks/useOTAUpdates.web.ts index 30cb2c6dba..c39067adef 100644 --- a/src/lib/hooks/useOTAUpdates.web.ts +++ b/src/lib/hooks/useOTAUpdates.web.ts @@ -8,3 +8,9 @@ export function useApplyPullRequestOTAUpdate() { pending: false, } } +/** + * There are no OTA updates on web, so the runtime is never one we reloaded into. + */ +export function consumeOTAReloadMarker() { + return false +} diff --git a/src/screens/Settings/components/OTAInfo.tsx b/src/screens/Settings/components/OTAInfo.tsx index 9ab1962b97..f93bfbe2b5 100644 --- a/src/screens/Settings/components/OTAInfo.tsx +++ b/src/screens/Settings/components/OTAInfo.tsx @@ -4,6 +4,7 @@ import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' import {useMutation, useQuery} from '@tanstack/react-query' +import {reloadWithUpdate} from '#/lib/hooks/useOTAUpdates' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate' import {Shapes_Stroke2_Corner0_Rounded as ShapesIcon} from '#/components/icons/Shapes' @@ -31,7 +32,7 @@ export function OTAInfo() { useMutation({ mutationFn: async () => { await Updates.fetchUpdateAsync() - await Updates.reloadAsync() + await reloadWithUpdate() }, onError: error => Toast.show(`Failed to update: ${error.message}`, { diff --git a/src/storage/schema.ts b/src/storage/schema.ts index 33c6bc0b56..7f9b5e2ecd 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -73,6 +73,22 @@ export type Device = { */ policyUpdateDebugOverride?: boolean [PolicyUpdate202508]?: boolean + + /** + * Timestamp of the last JS runtime reload we performed to apply an OTA + * update. Read and cleared once per runtime, see `consumeOTAReloadMarker`. + */ + otaReloadedAt?: number + /** + * The pull request OTA deployment last applied on this device, along with the + * id of the update it installed. Used to tell whether the update we're + * running came from a pull request channel, see + * `useApplyPullRequestOTAUpdate`. + */ + appliedOTADeployment?: { + channel: string + updateId: string + } } export type Account = {