Skip to content

fix(expo-context): Expo Updates context is passed to native after init #4808

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@

## Unreleased

### Fixes

- Expo Updates Context is passed to native after native init to be available for crashes ([#4808](https://github.com/getsentry/sentry-react-native/pull/4808))

### Dependencies

- Bump CLI from v2.43.1 to v2.44.0 ([#4804](https://github.com/getsentry/sentry-react-native/pull/4804))
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/js/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,26 @@ export class ReactNativeClient extends BaseClient<ReactNativeClientOptions> {
this._initNativeSdk();
}

/**
* Register a hook on this client.
*
* (Generic method signature to allow for custom React Native Client events.)
*/
public on(hook: string, callback: unknown): () => void {
// @ts-expect-error on from the base class doesn't support generic types
return super.on(hook, callback);
}

/**
* Emit a hook that was previously registered via `on()`.
*
* (Generic method signature to allow for custom React Native Client events.)
*/
public emit(hook: string, ...rest: unknown[]): void {
// @ts-expect-error emit from the base class doesn't support generic types
super.emit(hook, ...rest);
}

/**
* Starts native client with dsn and options
*/
Expand All @@ -165,6 +185,7 @@ export class ReactNativeClient extends BaseClient<ReactNativeClientOptions> {
)
.then((didCallNativeInit: boolean) => {
this._options.onReady?.({ didCallNativeInit });
this.emit('afterInit');
})
.then(undefined, error => {
logger.error('The OnReady callback threw an error: ', error);
Expand Down
11 changes: 9 additions & 2 deletions packages/core/src/js/integrations/expocontext.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type DeviceContext, type Event, type Integration, type OsContext, logger } from '@sentry/core';

import type { ReactNativeClient } from '../client';
import { isExpo, isExpoGo } from '../utils/environment';
import { getExpoDevice, getExpoUpdates } from '../utils/expomodules';
import { NATIVE } from '../wrapper';
Expand All @@ -12,8 +13,14 @@ export const OTA_UPDATES_CONTEXT_KEY = 'ota_updates';
export const expoContextIntegration = (): Integration => {
let _expoUpdatesContextCached: ExpoUpdatesContext | undefined;

function setup(): void {
setExpoUpdatesNativeContext();
function setup(client: ReactNativeClient): void {
client.on('afterInit', () => {
if (!client.getOptions().enableNative) {
return;
}

setExpoUpdatesNativeContext();
});
}

function setExpoUpdatesNativeContext(): void {
Expand Down
78 changes: 78 additions & 0 deletions packages/core/test/clientAfterInit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { ReactNativeClient } from '../src/js';
import type { ReactNativeClientOptions } from '../src/js/options';
import { NATIVE } from './mockWrapper';

jest.useFakeTimers({ advanceTimers: true });

jest.mock('../src/js/wrapper', () => jest.requireActual('./mockWrapper'));

describe('ReactNativeClient emits `afterInit` event', () => {
beforeEach(() => {
jest.clearAllMocks();
});

test('emits `afterInit` event when native is enabled', async () => {
const client = setupReactNativeClient({
enableNative: true,
});

const emitSpy = jest.spyOn(client, 'emit');
client.init();

await jest.runOnlyPendingTimersAsync();

expect(emitSpy).toHaveBeenCalledWith('afterInit');
});

test('emits `afterInit` event when native is disabled', async () => {
const client = setupReactNativeClient({
enableNative: false,
});

const emitSpy = jest.spyOn(client, 'emit');
client.init();

await jest.runOnlyPendingTimersAsync();
expect(emitSpy).toHaveBeenCalledWith('afterInit');
});

test('emits `afterInit` event when native init is rejected', async () => {
NATIVE.initNativeSdk = jest.fn().mockRejectedValue(new Error('Test Native Init Rejected'));

const client = setupReactNativeClient({
enableNative: false,
});

const emitSpy = jest.spyOn(client, 'emit');
client.init();

await jest.runOnlyPendingTimersAsync();
expect(emitSpy).toHaveBeenCalledWith('afterInit');
});
});

function setupReactNativeClient(options: Partial<ReactNativeClientOptions> = {}): ReactNativeClient {
return new ReactNativeClient({
...DEFAULT_OPTIONS,
...options,
});
}

const EXAMPLE_DSN = 'https://6890c2f6677340daa4804f8194804ea2@o19635.ingest.sentry.io/148053';

const DEFAULT_OPTIONS: ReactNativeClientOptions = {
dsn: EXAMPLE_DSN,
enableNative: true,
enableNativeCrashHandling: true,
enableNativeNagger: true,
autoInitializeNativeSdk: true,
enableAutoPerformanceTracing: true,
enableWatchdogTerminationTracking: true,
patchGlobalPromise: true,
integrations: [],
transport: () => ({
send: jest.fn(),
flush: jest.fn(),
}),
stackParser: jest.fn().mockReturnValue([]),
};
66 changes: 65 additions & 1 deletion packages/core/test/integrations/expocontext.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,80 @@
import type { Client, Event } from '@sentry/core';
import { type Client, type Event, getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core';

import { expoContextIntegration, OTA_UPDATES_CONTEXT_KEY } from '../../src/js/integrations/expocontext';
import * as environment from '../../src/js/utils/environment';
import type { ExpoUpdates } from '../../src/js/utils/expoglobalobject';
import { getExpoDevice } from '../../src/js/utils/expomodules';
import * as expoModules from '../../src/js/utils/expomodules';
import { setupTestClient } from '../mocks/client';
import { NATIVE } from '../mockWrapper';

jest.mock('../../src/js/wrapper', () => jest.requireActual('../mockWrapper'));
jest.mock('../../src/js/utils/expomodules');

describe('Expo Context Integration', () => {
afterEach(() => {
jest.clearAllMocks();

getCurrentScope().clear();
getIsolationScope().clear();
getGlobalScope().clear();
});

describe('Set Native Context after init()', () => {
beforeEach(() => {
jest.spyOn(expoModules, 'getExpoUpdates').mockReturnValue({
updateId: '123',
channel: 'default',
runtimeVersion: '1.0.0',
checkAutomatically: 'always',
emergencyLaunchReason: 'some reason',
launchDuration: 1000,
createdAt: new Date('2021-01-01T00:00:00.000Z'),
});
});

it('calls setContext when native enabled', () => {
jest.spyOn(environment, 'isExpo').mockReturnValue(true);
jest.spyOn(environment, 'isExpoGo').mockReturnValue(false);

setupTestClient({ enableNative: true, integrations: [expoContextIntegration()] });

expect(NATIVE.setContext).toHaveBeenCalledWith(
OTA_UPDATES_CONTEXT_KEY,
expect.objectContaining({
update_id: '123',
channel: 'default',
runtime_version: '1.0.0',
}),
);
});

it('does not call setContext when native disabled', () => {
jest.spyOn(environment, 'isExpo').mockReturnValue(true);
jest.spyOn(environment, 'isExpoGo').mockReturnValue(false);

setupTestClient({ enableNative: false, integrations: [expoContextIntegration()] });

expect(NATIVE.setContext).not.toHaveBeenCalled();
});

it('does not call setContext when not expo', () => {
jest.spyOn(environment, 'isExpo').mockReturnValue(false);
jest.spyOn(environment, 'isExpoGo').mockReturnValue(false);

setupTestClient({ enableNative: true, integrations: [expoContextIntegration()] });

expect(NATIVE.setContext).not.toHaveBeenCalled();
});

it('does not call setContext when expo go', () => {
jest.spyOn(environment, 'isExpo').mockReturnValue(true);
jest.spyOn(environment, 'isExpoGo').mockReturnValue(true);

setupTestClient({ enableNative: true, integrations: [expoContextIntegration()] });

expect(NATIVE.setContext).not.toHaveBeenCalled();
});
});

describe('Non Expo App', () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/core/test/mocks/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,5 +114,9 @@ export function setupTestClient(options: Partial<TestClientOptions> = {}): TestC
const client = new TestClient(finalOptions);
setCurrentClient(client);
client.init();

// @ts-expect-error Only available on ReactNativeClient
client.emit('afterInit');

return client;
}
Loading