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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEz4XBc47YabrrCzSfcRXj
This commit is contained in:
Claude
2026-07-26 20:18:21 +00:00
parent 2b9deeeb6a
commit 20f5c459d6
6 changed files with 197 additions and 15 deletions
@@ -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)
})
})
+18 -3
View File
@@ -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()
+95 -11
View File
@@ -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<NodeJS.Timeout>(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])
}
+6
View File
@@ -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
}
+2 -1
View File
@@ -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}`, {
+16
View File
@@ -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 = {