From 41618627ff524b5a18ec72054f87d3b8a8e083b6 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 10 Mar 2026 13:23:27 +0100 Subject: [PATCH 1/3] Expo Updates listener --- CHANGELOG.md | 3 + packages/core/src/js/integrations/default.ts | 2 + packages/core/src/js/integrations/exports.ts | 1 + .../js/integrations/expoupdateslistener.ts | 183 +++++++++++ .../integrations/expoupdateslistener.test.ts | 296 ++++++++++++++++++ 5 files changed, 485 insertions(+) create mode 100644 packages/core/src/js/integrations/expoupdateslistener.ts create mode 100644 packages/core/test/integrations/expoupdateslistener.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 046a2c08a9..c489685247 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ Sentry.wrapExpoAsset(Asset); ``` - Adds tags with Expo Updates context variables to make them searchable and filterable ([#5788](https://github.com/getsentry/sentry-react-native/pull/5788)) +- Add `expoUpdatesListenerIntegration` that records breadcrumbs for Expo Updates lifecycle events () + - Tracks update checks, downloads, errors, rollbacks, and restarts as `expo.updates` breadcrumbs + - Enabled by default in Expo apps (requires `expo-updates` to be installed) ## 8.3.0 diff --git a/packages/core/src/js/integrations/default.ts b/packages/core/src/js/integrations/default.ts index a3effed7c0..f4a9bd6599 100644 --- a/packages/core/src/js/integrations/default.ts +++ b/packages/core/src/js/integrations/default.ts @@ -19,6 +19,7 @@ import { eventOriginIntegration, expoConstantsIntegration, expoContextIntegration, + expoUpdatesListenerIntegration, functionToStringIntegration, hermesProfilingIntegration, httpClientIntegration, @@ -133,6 +134,7 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ integrations.push(expoContextIntegration()); integrations.push(expoConstantsIntegration()); + integrations.push(expoUpdatesListenerIntegration()); if (options.spotlight && __DEV__) { const sidecarUrl = typeof options.spotlight === 'string' ? options.spotlight : undefined; diff --git a/packages/core/src/js/integrations/exports.ts b/packages/core/src/js/integrations/exports.ts index bc228de280..d4e80f8ef6 100644 --- a/packages/core/src/js/integrations/exports.ts +++ b/packages/core/src/js/integrations/exports.ts @@ -12,6 +12,7 @@ export { screenshotIntegration } from './screenshot'; export { viewHierarchyIntegration } from './viewhierarchy'; export { expoContextIntegration } from './expocontext'; export { expoConstantsIntegration } from './expoconstants'; +export { expoUpdatesListenerIntegration } from './expoupdateslistener'; export { spotlightIntegration } from './spotlight'; export { mobileReplayIntegration } from '../replay/mobilereplay'; export { feedbackIntegration } from '../feedback/integration'; diff --git a/packages/core/src/js/integrations/expoupdateslistener.ts b/packages/core/src/js/integrations/expoupdateslistener.ts new file mode 100644 index 0000000000..39d7a453c3 --- /dev/null +++ b/packages/core/src/js/integrations/expoupdateslistener.ts @@ -0,0 +1,183 @@ +import { addBreadcrumb, debug, type Integration } from '@sentry/core'; +import type { ReactNativeClient } from '../client'; +import { isExpo, isExpoGo } from '../utils/environment'; + +const INTEGRATION_NAME = 'ExpoUpdatesListener'; + +const BREADCRUMB_CATEGORY = 'expo.updates'; + +/** + * Describes the state machine context from `expo-updates`. + * We define our own minimal type to avoid a hard dependency on `expo-updates`. + */ +interface UpdatesNativeStateMachineContext { + isChecking: boolean; + isDownloading: boolean; + isUpdateAvailable: boolean; + isUpdatePending: boolean; + isRestarting: boolean; + latestManifest?: { id?: string }; + downloadedManifest?: { id?: string }; + rollback?: { commitTime: string }; + checkError?: Error; + downloadError?: Error; +} + +interface UpdatesNativeStateChangeEvent { + context: UpdatesNativeStateMachineContext; +} + +interface UpdatesStateChangeSubscription { + remove(): void; +} + +/** + * Tries to load `expo-updates` and retrieve `addUpdatesStateChangeListener`. + * Returns `undefined` if `expo-updates` is not installed. + */ +function getAddUpdatesStateChangeListener(): (( + listener: (event: UpdatesNativeStateChangeEvent) => void, +) => UpdatesStateChangeSubscription) | undefined { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const expoUpdates = require('expo-updates'); + if (typeof expoUpdates.addUpdatesStateChangeListener === 'function') { + return expoUpdates.addUpdatesStateChangeListener; + } + } catch (_) { + // expo-updates is not installed + } + return undefined; +} + +/** + * Listens to Expo Updates native state machine changes and records + * breadcrumbs for meaningful transitions such as checking for updates, + * downloading updates, errors, rollbacks, and restarts. + */ +export const expoUpdatesListenerIntegration = (): Integration => { + let _subscription: UpdatesStateChangeSubscription | undefined; + + function setup(client: ReactNativeClient): void { + client.on('afterInit', () => { + if (!isExpo() || isExpoGo()) { + return; + } + + const addListener = getAddUpdatesStateChangeListener(); + if (!addListener) { + debug.log('[ExpoUpdatesListener] expo-updates is not available, skipping.'); + return; + } + + let previousContext: Partial = {}; + + _subscription = addListener((event: UpdatesNativeStateChangeEvent) => { + const ctx = event.context; + handleStateChange(previousContext, ctx); + previousContext = ctx; + }); + }); + } + + return { + name: INTEGRATION_NAME, + setup, + }; +}; + +/** + * Compares previous and current state machine contexts and emits + * breadcrumbs for meaningful transitions. + * + * @internal Exposed for testing purposes + */ +export function handleStateChange( + previous: Partial, + current: UpdatesNativeStateMachineContext, +): void { + // Checking for update + if (!previous.isChecking && current.isChecking) { + addBreadcrumb({ + category: BREADCRUMB_CATEGORY, + message: 'Checking for update', + level: 'info', + }); + } + + // Update available + if (!previous.isUpdateAvailable && current.isUpdateAvailable) { + const updateId = current.latestManifest?.id; + addBreadcrumb({ + category: BREADCRUMB_CATEGORY, + message: 'Update available', + level: 'info', + data: updateId ? { updateId } : undefined, + }); + } + + // Downloading update + if (!previous.isDownloading && current.isDownloading) { + addBreadcrumb({ + category: BREADCRUMB_CATEGORY, + message: 'Downloading update', + level: 'info', + }); + } + + // Update downloaded and pending + if (!previous.isUpdatePending && current.isUpdatePending) { + const updateId = current.downloadedManifest?.id; + addBreadcrumb({ + category: BREADCRUMB_CATEGORY, + message: 'Update downloaded', + level: 'info', + data: updateId ? { updateId } : undefined, + }); + } + + // Check error + if (!previous.checkError && current.checkError) { + addBreadcrumb({ + category: BREADCRUMB_CATEGORY, + message: 'Update check failed', + level: 'error', + data: { + error: current.checkError.message || String(current.checkError), + }, + }); + } + + // Download error + if (!previous.downloadError && current.downloadError) { + addBreadcrumb({ + category: BREADCRUMB_CATEGORY, + message: 'Update download failed', + level: 'error', + data: { + error: current.downloadError.message || String(current.downloadError), + }, + }); + } + + // Rollback + if (!previous.rollback && current.rollback) { + addBreadcrumb({ + category: BREADCRUMB_CATEGORY, + message: 'Rollback directive received', + level: 'warning', + data: { + commitTime: current.rollback.commitTime, + }, + }); + } + + // Restarting + if (!previous.isRestarting && current.isRestarting) { + addBreadcrumb({ + category: BREADCRUMB_CATEGORY, + message: 'Restarting for update', + level: 'info', + }); + } +} diff --git a/packages/core/test/integrations/expoupdateslistener.test.ts b/packages/core/test/integrations/expoupdateslistener.test.ts new file mode 100644 index 0000000000..b867095942 --- /dev/null +++ b/packages/core/test/integrations/expoupdateslistener.test.ts @@ -0,0 +1,296 @@ +import { addBreadcrumb, getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core'; +import { + expoUpdatesListenerIntegration, + handleStateChange, +} from '../../src/js/integrations/expoupdateslistener'; +import * as environment from '../../src/js/utils/environment'; +import { setupTestClient } from '../mocks/client'; + +jest.mock('../../src/js/wrapper', () => jest.requireActual('../mockWrapper')); +jest.mock('@sentry/core', () => { + const actual = jest.requireActual('@sentry/core'); + return { + ...actual, + addBreadcrumb: jest.fn(), + }; +}); + +const mockAddBreadcrumb = addBreadcrumb as jest.MockedFunction; + +describe('ExpoUpdatesListener Integration', () => { + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + }); + + describe('setup', () => { + it('subscribes to state changes when expo-updates is available', () => { + jest.spyOn(environment, 'isExpo').mockReturnValue(true); + jest.spyOn(environment, 'isExpoGo').mockReturnValue(false); + + const mockRemove = jest.fn(); + const mockAddListener = jest.fn().mockReturnValue({ remove: mockRemove }); + jest.mock('expo-updates', () => ({ + addUpdatesStateChangeListener: mockAddListener, + }), { virtual: true }); + + setupTestClient({ enableNative: true, integrations: [expoUpdatesListenerIntegration()] }); + + expect(mockAddListener).toHaveBeenCalledTimes(1); + expect(mockAddListener).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('does not subscribe when not expo', () => { + jest.spyOn(environment, 'isExpo').mockReturnValue(false); + jest.spyOn(environment, 'isExpoGo').mockReturnValue(false); + + const mockAddListener = jest.fn(); + jest.mock('expo-updates', () => ({ + addUpdatesStateChangeListener: mockAddListener, + }), { virtual: true }); + + setupTestClient({ enableNative: true, integrations: [expoUpdatesListenerIntegration()] }); + + expect(mockAddListener).not.toHaveBeenCalled(); + }); + + it('does not subscribe when in Expo Go', () => { + jest.spyOn(environment, 'isExpo').mockReturnValue(true); + jest.spyOn(environment, 'isExpoGo').mockReturnValue(true); + + const mockAddListener = jest.fn(); + jest.mock('expo-updates', () => ({ + addUpdatesStateChangeListener: mockAddListener, + }), { virtual: true }); + + setupTestClient({ enableNative: true, integrations: [expoUpdatesListenerIntegration()] }); + + expect(mockAddListener).not.toHaveBeenCalled(); + }); + }); + + describe('handleStateChange', () => { + const baseContext = { + isChecking: false, + isDownloading: false, + isUpdateAvailable: false, + isUpdatePending: false, + isRestarting: false, + }; + + beforeEach(() => { + mockAddBreadcrumb.mockClear(); + }); + + it('adds breadcrumb when checking starts', () => { + handleStateChange( + { ...baseContext }, + { ...baseContext, isChecking: true }, + ); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + category: 'expo.updates', + message: 'Checking for update', + level: 'info', + }); + }); + + it('does not add breadcrumb when checking stays true', () => { + handleStateChange( + { ...baseContext, isChecking: true }, + { ...baseContext, isChecking: true }, + ); + + expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + }); + + it('adds breadcrumb when update becomes available', () => { + handleStateChange( + { ...baseContext }, + { + ...baseContext, + isUpdateAvailable: true, + latestManifest: { id: 'abc-123' }, + }, + ); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + category: 'expo.updates', + message: 'Update available', + level: 'info', + data: { updateId: 'abc-123' }, + }); + }); + + it('adds breadcrumb when update available without manifest id', () => { + handleStateChange( + { ...baseContext }, + { + ...baseContext, + isUpdateAvailable: true, + }, + ); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + category: 'expo.updates', + message: 'Update available', + level: 'info', + data: undefined, + }); + }); + + it('adds breadcrumb when downloading starts', () => { + handleStateChange( + { ...baseContext }, + { ...baseContext, isDownloading: true }, + ); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + category: 'expo.updates', + message: 'Downloading update', + level: 'info', + }); + }); + + it('adds breadcrumb when update is downloaded and pending', () => { + handleStateChange( + { ...baseContext }, + { + ...baseContext, + isUpdatePending: true, + downloadedManifest: { id: 'def-456' }, + }, + ); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + category: 'expo.updates', + message: 'Update downloaded', + level: 'info', + data: { updateId: 'def-456' }, + }); + }); + + it('adds breadcrumb when check error occurs', () => { + handleStateChange( + { ...baseContext }, + { + ...baseContext, + checkError: new Error('Network request failed'), + }, + ); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + category: 'expo.updates', + message: 'Update check failed', + level: 'error', + data: { error: 'Network request failed' }, + }); + }); + + it('adds breadcrumb when download error occurs', () => { + handleStateChange( + { ...baseContext }, + { + ...baseContext, + downloadError: new Error('Insufficient storage'), + }, + ); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + category: 'expo.updates', + message: 'Update download failed', + level: 'error', + data: { error: 'Insufficient storage' }, + }); + }); + + it('adds breadcrumb when rollback is received', () => { + handleStateChange( + { ...baseContext }, + { + ...baseContext, + rollback: { commitTime: '2025-03-01T00:00:00.000Z' }, + }, + ); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + category: 'expo.updates', + message: 'Rollback directive received', + level: 'warning', + data: { commitTime: '2025-03-01T00:00:00.000Z' }, + }); + }); + + it('adds breadcrumb when restarting starts', () => { + handleStateChange( + { ...baseContext }, + { ...baseContext, isRestarting: true }, + ); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + category: 'expo.updates', + message: 'Restarting for update', + level: 'info', + }); + }); + + it('adds multiple breadcrumbs for multiple transitions', () => { + handleStateChange( + { ...baseContext }, + { + ...baseContext, + isChecking: true, + isDownloading: true, + }, + ); + + expect(mockAddBreadcrumb).toHaveBeenCalledTimes(2); + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Checking for update' }), + ); + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Downloading update' }), + ); + }); + + it('does not add breadcrumbs when nothing changes', () => { + handleStateChange( + { ...baseContext }, + { ...baseContext }, + ); + + expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + }); + + it('does not re-emit breadcrumbs for already-present errors', () => { + const existingError = new Error('Old error'); + handleStateChange( + { ...baseContext, checkError: existingError }, + { ...baseContext, checkError: existingError }, + ); + + expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + }); + + it('uses String fallback when error has no message', () => { + const errorWithoutMessage = { toString: () => 'Custom error string' } as unknown as Error; + handleStateChange( + { ...baseContext }, + { + ...baseContext, + checkError: errorWithoutMessage, + }, + ); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + data: { error: 'Custom error string' }, + }), + ); + }); + }); +}); From 663d6c9880d5d4e0b1f150a6e8c43d6f1055d900 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 12 Mar 2026 14:56:07 +0100 Subject: [PATCH 2/3] Lint fixes --- .../js/integrations/expoupdateslistener.ts | 175 ++++++++---------- .../integrations/expoupdateslistener.test.ts | 73 +++----- 2 files changed, 105 insertions(+), 143 deletions(-) diff --git a/packages/core/src/js/integrations/expoupdateslistener.ts b/packages/core/src/js/integrations/expoupdateslistener.ts index 39d7a453c3..c13de623a1 100644 --- a/packages/core/src/js/integrations/expoupdateslistener.ts +++ b/packages/core/src/js/integrations/expoupdateslistener.ts @@ -1,4 +1,4 @@ -import { addBreadcrumb, debug, type Integration } from '@sentry/core'; +import { addBreadcrumb, debug, type Integration, type SeverityLevel } from '@sentry/core'; import type { ReactNativeClient } from '../client'; import { isExpo, isExpoGo } from '../utils/environment'; @@ -27,37 +27,86 @@ interface UpdatesNativeStateChangeEvent { context: UpdatesNativeStateMachineContext; } -interface UpdatesStateChangeSubscription { - remove(): void; -} - /** * Tries to load `expo-updates` and retrieve `addUpdatesStateChangeListener`. * Returns `undefined` if `expo-updates` is not installed. */ -function getAddUpdatesStateChangeListener(): (( - listener: (event: UpdatesNativeStateChangeEvent) => void, -) => UpdatesStateChangeSubscription) | undefined { +function getAddUpdatesStateChangeListener(): + | ((listener: (event: UpdatesNativeStateChangeEvent) => void) => void) + | undefined { try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const expoUpdates = require('expo-updates'); - if (typeof expoUpdates.addUpdatesStateChangeListener === 'function') { - return expoUpdates.addUpdatesStateChangeListener; + // eslint-disable-next-line @typescript-eslint/no-var-requires,@typescript-eslint/no-unsafe-member-access + const addListener = require('expo-updates').addUpdatesStateChangeListener; + if (typeof addListener === 'function') { + return addListener as (listener: (event: UpdatesNativeStateChangeEvent) => void) => void; } } catch (_) { - // expo-updates is not installed + // that happens when expo-updates is not installed } return undefined; } +interface StateTransition { + field: keyof UpdatesNativeStateMachineContext; + message: string; + level: SeverityLevel; + getData?: (ctx: UpdatesNativeStateMachineContext) => Record | undefined; +} + +const STATE_TRANSITIONS: StateTransition[] = [ + { field: 'isChecking', message: 'Checking for update', level: 'info' }, + { + field: 'isUpdateAvailable', + message: 'Update available', + level: 'info', + getData: ctx => { + const updateId = ctx.latestManifest?.id; + return updateId ? { updateId } : undefined; + }, + }, + { field: 'isDownloading', message: 'Downloading update', level: 'info' }, + { + field: 'isUpdatePending', + message: 'Update downloaded', + level: 'info', + getData: ctx => { + const updateId = ctx.downloadedManifest?.id; + return updateId ? { updateId } : undefined; + }, + }, + { + field: 'checkError', + message: 'Update check failed', + level: 'error', + getData: ctx => ({ + error: (ctx.checkError as Error).message || String(ctx.checkError), + }), + }, + { + field: 'downloadError', + message: 'Update download failed', + level: 'error', + getData: ctx => ({ + error: (ctx.downloadError as Error).message || String(ctx.downloadError), + }), + }, + { + field: 'rollback', + message: 'Rollback directive received', + level: 'warning', + getData: ctx => ({ + commitTime: ctx.rollback!.commitTime, + }), + }, + { field: 'isRestarting', message: 'Restarting for update', level: 'info' }, +]; + /** * Listens to Expo Updates native state machine changes and records * breadcrumbs for meaningful transitions such as checking for updates, * downloading updates, errors, rollbacks, and restarts. */ export const expoUpdatesListenerIntegration = (): Integration => { - let _subscription: UpdatesStateChangeSubscription | undefined; - function setup(client: ReactNativeClient): void { client.on('afterInit', () => { if (!isExpo() || isExpoGo()) { @@ -72,7 +121,7 @@ export const expoUpdatesListenerIntegration = (): Integration => { let previousContext: Partial = {}; - _subscription = addListener((event: UpdatesNativeStateChangeEvent) => { + addListener((event: UpdatesNativeStateChangeEvent) => { const ctx = event.context; handleStateChange(previousContext, ctx); previousContext = ctx; @@ -88,7 +137,7 @@ export const expoUpdatesListenerIntegration = (): Integration => { /** * Compares previous and current state machine contexts and emits - * breadcrumbs for meaningful transitions. + * breadcrumbs for meaningful transitions (falsy→truthy). * * @internal Exposed for testing purposes */ @@ -96,88 +145,14 @@ export function handleStateChange( previous: Partial, current: UpdatesNativeStateMachineContext, ): void { - // Checking for update - if (!previous.isChecking && current.isChecking) { - addBreadcrumb({ - category: BREADCRUMB_CATEGORY, - message: 'Checking for update', - level: 'info', - }); - } - - // Update available - if (!previous.isUpdateAvailable && current.isUpdateAvailable) { - const updateId = current.latestManifest?.id; - addBreadcrumb({ - category: BREADCRUMB_CATEGORY, - message: 'Update available', - level: 'info', - data: updateId ? { updateId } : undefined, - }); - } - - // Downloading update - if (!previous.isDownloading && current.isDownloading) { - addBreadcrumb({ - category: BREADCRUMB_CATEGORY, - message: 'Downloading update', - level: 'info', - }); - } - - // Update downloaded and pending - if (!previous.isUpdatePending && current.isUpdatePending) { - const updateId = current.downloadedManifest?.id; - addBreadcrumb({ - category: BREADCRUMB_CATEGORY, - message: 'Update downloaded', - level: 'info', - data: updateId ? { updateId } : undefined, - }); - } - - // Check error - if (!previous.checkError && current.checkError) { - addBreadcrumb({ - category: BREADCRUMB_CATEGORY, - message: 'Update check failed', - level: 'error', - data: { - error: current.checkError.message || String(current.checkError), - }, - }); - } - - // Download error - if (!previous.downloadError && current.downloadError) { - addBreadcrumb({ - category: BREADCRUMB_CATEGORY, - message: 'Update download failed', - level: 'error', - data: { - error: current.downloadError.message || String(current.downloadError), - }, - }); - } - - // Rollback - if (!previous.rollback && current.rollback) { - addBreadcrumb({ - category: BREADCRUMB_CATEGORY, - message: 'Rollback directive received', - level: 'warning', - data: { - commitTime: current.rollback.commitTime, - }, - }); - } - - // Restarting - if (!previous.isRestarting && current.isRestarting) { - addBreadcrumb({ - category: BREADCRUMB_CATEGORY, - message: 'Restarting for update', - level: 'info', - }); + for (const transition of STATE_TRANSITIONS) { + if (!previous[transition.field] && current[transition.field]) { + addBreadcrumb({ + category: BREADCRUMB_CATEGORY, + message: transition.message, + level: transition.level, + data: transition.getData?.(current), + }); + } } } diff --git a/packages/core/test/integrations/expoupdateslistener.test.ts b/packages/core/test/integrations/expoupdateslistener.test.ts index b867095942..c473ac07ea 100644 --- a/packages/core/test/integrations/expoupdateslistener.test.ts +++ b/packages/core/test/integrations/expoupdateslistener.test.ts @@ -1,8 +1,5 @@ import { addBreadcrumb, getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core'; -import { - expoUpdatesListenerIntegration, - handleStateChange, -} from '../../src/js/integrations/expoupdateslistener'; +import { expoUpdatesListenerIntegration, handleStateChange } from '../../src/js/integrations/expoupdateslistener'; import * as environment from '../../src/js/utils/environment'; import { setupTestClient } from '../mocks/client'; @@ -34,9 +31,13 @@ describe('ExpoUpdatesListener Integration', () => { const mockRemove = jest.fn(); const mockAddListener = jest.fn().mockReturnValue({ remove: mockRemove }); - jest.mock('expo-updates', () => ({ - addUpdatesStateChangeListener: mockAddListener, - }), { virtual: true }); + jest.mock( + 'expo-updates', + () => ({ + addUpdatesStateChangeListener: mockAddListener, + }), + { virtual: true }, + ); setupTestClient({ enableNative: true, integrations: [expoUpdatesListenerIntegration()] }); @@ -49,9 +50,13 @@ describe('ExpoUpdatesListener Integration', () => { jest.spyOn(environment, 'isExpoGo').mockReturnValue(false); const mockAddListener = jest.fn(); - jest.mock('expo-updates', () => ({ - addUpdatesStateChangeListener: mockAddListener, - }), { virtual: true }); + jest.mock( + 'expo-updates', + () => ({ + addUpdatesStateChangeListener: mockAddListener, + }), + { virtual: true }, + ); setupTestClient({ enableNative: true, integrations: [expoUpdatesListenerIntegration()] }); @@ -63,9 +68,13 @@ describe('ExpoUpdatesListener Integration', () => { jest.spyOn(environment, 'isExpoGo').mockReturnValue(true); const mockAddListener = jest.fn(); - jest.mock('expo-updates', () => ({ - addUpdatesStateChangeListener: mockAddListener, - }), { virtual: true }); + jest.mock( + 'expo-updates', + () => ({ + addUpdatesStateChangeListener: mockAddListener, + }), + { virtual: true }, + ); setupTestClient({ enableNative: true, integrations: [expoUpdatesListenerIntegration()] }); @@ -87,10 +96,7 @@ describe('ExpoUpdatesListener Integration', () => { }); it('adds breadcrumb when checking starts', () => { - handleStateChange( - { ...baseContext }, - { ...baseContext, isChecking: true }, - ); + handleStateChange({ ...baseContext }, { ...baseContext, isChecking: true }); expect(mockAddBreadcrumb).toHaveBeenCalledWith({ category: 'expo.updates', @@ -100,10 +106,7 @@ describe('ExpoUpdatesListener Integration', () => { }); it('does not add breadcrumb when checking stays true', () => { - handleStateChange( - { ...baseContext, isChecking: true }, - { ...baseContext, isChecking: true }, - ); + handleStateChange({ ...baseContext, isChecking: true }, { ...baseContext, isChecking: true }); expect(mockAddBreadcrumb).not.toHaveBeenCalled(); }); @@ -144,10 +147,7 @@ describe('ExpoUpdatesListener Integration', () => { }); it('adds breadcrumb when downloading starts', () => { - handleStateChange( - { ...baseContext }, - { ...baseContext, isDownloading: true }, - ); + handleStateChange({ ...baseContext }, { ...baseContext, isDownloading: true }); expect(mockAddBreadcrumb).toHaveBeenCalledWith({ category: 'expo.updates', @@ -226,10 +226,7 @@ describe('ExpoUpdatesListener Integration', () => { }); it('adds breadcrumb when restarting starts', () => { - handleStateChange( - { ...baseContext }, - { ...baseContext, isRestarting: true }, - ); + handleStateChange({ ...baseContext }, { ...baseContext, isRestarting: true }); expect(mockAddBreadcrumb).toHaveBeenCalledWith({ category: 'expo.updates', @@ -249,29 +246,19 @@ describe('ExpoUpdatesListener Integration', () => { ); expect(mockAddBreadcrumb).toHaveBeenCalledTimes(2); - expect(mockAddBreadcrumb).toHaveBeenCalledWith( - expect.objectContaining({ message: 'Checking for update' }), - ); - expect(mockAddBreadcrumb).toHaveBeenCalledWith( - expect.objectContaining({ message: 'Downloading update' }), - ); + expect(mockAddBreadcrumb).toHaveBeenCalledWith(expect.objectContaining({ message: 'Checking for update' })); + expect(mockAddBreadcrumb).toHaveBeenCalledWith(expect.objectContaining({ message: 'Downloading update' })); }); it('does not add breadcrumbs when nothing changes', () => { - handleStateChange( - { ...baseContext }, - { ...baseContext }, - ); + handleStateChange({ ...baseContext }, { ...baseContext }); expect(mockAddBreadcrumb).not.toHaveBeenCalled(); }); it('does not re-emit breadcrumbs for already-present errors', () => { const existingError = new Error('Old error'); - handleStateChange( - { ...baseContext, checkError: existingError }, - { ...baseContext, checkError: existingError }, - ); + handleStateChange({ ...baseContext, checkError: existingError }, { ...baseContext, checkError: existingError }); expect(mockAddBreadcrumb).not.toHaveBeenCalled(); }); From 930859602b5f986f00f8c11c7bc00c555ed3583a Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 12 Mar 2026 14:57:35 +0100 Subject: [PATCH 3/3] Changelog fix --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c489685247..fc2306bc30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ Sentry.wrapExpoAsset(Asset); ``` - Adds tags with Expo Updates context variables to make them searchable and filterable ([#5788](https://github.com/getsentry/sentry-react-native/pull/5788)) -- Add `expoUpdatesListenerIntegration` that records breadcrumbs for Expo Updates lifecycle events () +- Add `expoUpdatesListenerIntegration` that records breadcrumbs for Expo Updates lifecycle events ([#5795](https://github.com/getsentry/sentry-react-native/pull/5795)) - Tracks update checks, downloads, errors, rollbacks, and restarts as `expo.updates` breadcrumbs - Enabled by default in Expo apps (requires `expo-updates` to be installed)