Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
313e844
useNativeInit Android implementation
antonis Mar 7, 2025
2e97acc
Adds changelog
antonis Mar 7, 2025
6eedaae
useNativeInit iOS implementation
antonis Mar 7, 2025
9ae5475
Fix indentation
antonis Mar 7, 2025
566550e
Extend test cases with realistic data
antonis Mar 7, 2025
770c9f4
Adds code sample in the changelog
antonis Mar 7, 2025
f8b37b5
Fix CHANGELOG.md
antonis Apr 4, 2025
d25db30
Warn if RESentySDK.init/start wasn't injected
antonis Apr 4, 2025
adc81a5
Make useNativeInit opt-in
antonis Apr 15, 2025
8c2cd73
Make Android failure warning more clear
antonis Apr 15, 2025
a2b5575
Make Android no update warning more clear
antonis Apr 15, 2025
5f4f7c5
Use path.basename to get last path component
antonis Apr 15, 2025
0431cc3
Update tests to account for the new warnings
antonis Apr 15, 2025
62d39cc
Explicitly check for kotlin
antonis Apr 16, 2025
235f3ef
Add filename in the warning message
antonis Apr 16, 2025
369cce7
Import only if init injection succeeds
antonis Apr 16, 2025
a53c7f4
Explicitly check for Objective-C
antonis Apr 16, 2025
5e4a98f
Add filename in the warning
antonis Apr 16, 2025
dce74b2
Make iOS file not found warning more clear
antonis Apr 16, 2025
0ffd26c
Import only if init injection succeeds
antonis Apr 16, 2025
744993c
Reset test mock config in a function
antonis Apr 16, 2025
5447be9
Lint issue
antonis Apr 16, 2025
0b3423f
Add missing quote
antonis Apr 24, 2025
5c615fd
Remove unneeded async
antonis Jun 10, 2025
c356288
Set useNativeInit = false by default
antonis Jun 10, 2025
8e32556
dynamically fill white spaces
antonis Jun 10, 2025
a20984c
Add unsupported language in warning message
antonis Jun 11, 2025
d1db4fa
Add objcpp in detected languages
antonis Jun 11, 2025
7c25c2c
Merge branch 'antonis/4625-expo-useNativeInit' of https://github.com/…
antonis Jun 11, 2025
1918baf
Update tests for objcpp
antonis Jun 11, 2025
b2a89f2
ref(expo-plugin): Split utils to logger, version and utils (#4906)
krystofwoldrich Jun 16, 2025
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@

### Features

- Add RNSentrySDK APIs support to @sentry/react-native/expo plugin ([#4633](https://github.com/getsentry/sentry-react-native/pull/4633))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. It would be nice to include an example code snippet and a small summary of what will the flag do.


This feature is opt-out to enable it set `useNativeInit` to `true` in your `@sentry/react-native/expo` plugin configuration.

```js
"plugins": [
[
"@sentry/react-native/expo",
{
"useNativeInit": true
}
],
```

- User Feedback Widget Beta ([#4435](https://github.com/getsentry/sentry-react-native/pull/4435))

To collect user feedback from inside your application call `Sentry.showFeedbackWidget()`.
Expand Down
5 changes: 3 additions & 2 deletions packages/core/plugin/src/withSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface PluginProps {
project?: string;
authToken?: string;
url?: string;
useNativeInit?: boolean;
experimental_android?: SentryAndroidGradlePluginOptions;
}

Expand All @@ -26,7 +27,7 @@ const withSentryPlugin: ConfigPlugin<PluginProps | void> = (config, props) => {
let cfg = config;
if (sentryProperties !== null) {
try {
cfg = withSentryAndroid(cfg, sentryProperties);
cfg = withSentryAndroid(cfg, { sentryProperties, useNativeInit: props?.useNativeInit });
} catch (e) {
warnOnce(`There was a problem with configuring your native Android project: ${e}`);
}
Expand All @@ -39,7 +40,7 @@ const withSentryPlugin: ConfigPlugin<PluginProps | void> = (config, props) => {
}
}
try {
cfg = withSentryIOS(cfg, sentryProperties);
cfg = withSentryIOS(cfg, { sentryProperties, useNativeInit: props?.useNativeInit });
} catch (e) {
warnOnce(`There was a problem with configuring your native iOS project: ${e}`);
}
Expand Down
62 changes: 58 additions & 4 deletions packages/core/plugin/src/withSentryAndroid.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
import type { ExpoConfig } from '@expo/config-types';
import type { ConfigPlugin } from 'expo/config-plugins';
import { withAppBuildGradle, withDangerousMod } from 'expo/config-plugins';
import { withAppBuildGradle, withDangerousMod, withMainApplication } from 'expo/config-plugins';
import * as path from 'path';

import { warnOnce, writeSentryPropertiesTo } from './utils';

export const withSentryAndroid: ConfigPlugin<string> = (config, sentryProperties: string) => {
const cfg = withAppBuildGradle(config, config => {
export const withSentryAndroid: ConfigPlugin<{ sentryProperties: string; useNativeInit: boolean | undefined }> = (
config,
{ sentryProperties, useNativeInit = false },
) => {
const appBuildGradleCfg = withAppBuildGradle(config, config => {
if (config.modResults.language === 'groovy') {
config.modResults.contents = modifyAppBuildGradle(config.modResults.contents);
} else {
throw new Error('Cannot configure Sentry in the app gradle because the build.gradle is not groovy');
}
return config;
});
return withDangerousMod(cfg, [

const mainApplicationCfg = useNativeInit ? modifyMainApplication(appBuildGradleCfg) : appBuildGradleCfg;

return withDangerousMod(mainApplicationCfg, [
'android',
config => {
writeSentryPropertiesTo(path.resolve(config.modRequest.projectRoot, 'android'), sentryProperties);
Expand Down Expand Up @@ -49,3 +56,50 @@ export function modifyAppBuildGradle(buildGradle: string): string {

return buildGradle.replace(pattern, match => `${applyFrom}\n\n${match}`);
}

export function modifyMainApplication(config: ExpoConfig): ExpoConfig {
return withMainApplication(config, async config => {
if (!config.modResults || !config.modResults.path) {
warnOnce('Skipping MainApplication modification because the file does not exist.');
return config;
}

const fileName = config.modResults.path.split('/').pop();

if (config.modResults.contents.includes('RNSentrySDK.init')) {
warnOnce(`Your '${fileName}' already contains 'RNSentrySDK.init'.`);
return config;
}

if (config.modResults.language === 'java') {
if (!config.modResults.contents.includes('import io.sentry.react.RNSentrySDK;')) {
// Insert import statement after package declaration
config.modResults.contents = config.modResults.contents.replace(
/(package .*;\n\n?)/,
`$1import io.sentry.react.RNSentrySDK;\n`,
);
}
// Add RNSentrySDK.init
config.modResults.contents = config.modResults.contents.replace(
/(super\.onCreate\(\)[;\n]*)([ \t]*)/,
`$1\n$2RNSentrySDK.init(this);\n$2`,
);
} else {
// Kotlin
if (!config.modResults.contents.includes('import io.sentry.react.RNSentrySDK')) {
// Insert import statement after package declaration
config.modResults.contents = config.modResults.contents.replace(
/(package .*\n\n?)/,
`$1import io.sentry.react.RNSentrySDK\n`,
);
}
// Add RNSentrySDK.init
config.modResults.contents = config.modResults.contents.replace(
/(super\.onCreate\(\)[;\n]*)([ \t]*)/,
`$1\n$2RNSentrySDK.init(this)\n$2`,
);
}

return config;
});
}
61 changes: 57 additions & 4 deletions packages/core/plugin/src/withSentryIOS.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ExpoConfig } from '@expo/config-types';
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import type { ConfigPlugin, XcodeProject } from 'expo/config-plugins';
import { withDangerousMod, withXcodeProject } from 'expo/config-plugins';
import { withAppDelegate, withDangerousMod, withXcodeProject } from 'expo/config-plugins';
import * as path from 'path';

import { warnOnce, writeSentryPropertiesTo } from './utils';
Expand All @@ -12,8 +13,11 @@ const SENTRY_REACT_NATIVE_XCODE_PATH =
const SENTRY_REACT_NATIVE_XCODE_DEBUG_FILES_PATH =
"`${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`";

export const withSentryIOS: ConfigPlugin<string> = (config, sentryProperties: string) => {
const cfg = withXcodeProject(config, config => {
export const withSentryIOS: ConfigPlugin<{ sentryProperties: string; useNativeInit: boolean | undefined }> = (
config,
{ sentryProperties, useNativeInit = false },
) => {
const xcodeProjectCfg = withXcodeProject(config, config => {
const xcodeProject: XcodeProject = config.modResults;

const sentryBuildPhase = xcodeProject.pbxItemByComment(
Expand All @@ -36,7 +40,9 @@ export const withSentryIOS: ConfigPlugin<string> = (config, sentryProperties: st
return config;
});

return withDangerousMod(cfg, [
const appDelegateCfc = useNativeInit ? modifyAppDelegate(xcodeProjectCfg) : xcodeProjectCfg;

return withDangerousMod(appDelegateCfc, [
'ios',
config => {
writeSentryPropertiesTo(path.resolve(config.modRequest.projectRoot, 'ios'), sentryProperties);
Expand Down Expand Up @@ -79,3 +85,50 @@ export function addSentryWithBundledScriptsToBundleShellScript(script: string):
(match: string) => `/bin/sh ${SENTRY_REACT_NATIVE_XCODE_PATH} ${match}`,
);
}

export function modifyAppDelegate(config: ExpoConfig): ExpoConfig {
return withAppDelegate(config, async config => {
if (!config.modResults || !config.modResults.path) {
warnOnce('Skipping AppDelegate modification because the file does not exist.');
return config;
}

const fileName = config.modResults.path.split('/').pop();

if (config.modResults.language === 'swift') {
if (config.modResults.contents.includes('RNSentrySDK.start()')) {
warnOnce(`Your '${fileName}' already contains 'RNSentrySDK.start()'.`);
return config;
}
if (!config.modResults.contents.includes('import RNSentry')) {
// Insert import statement after UIKit import
config.modResults.contents = config.modResults.contents.replace(/(import UIKit\n)/, `$1import RNSentry\n`);
}
// Add RNSentrySDK.start() at the beginning of application method
config.modResults.contents = config.modResults.contents.replace(
/(func application\([^)]*\) -> Bool \{)/s,
`$1\n RNSentrySDK.start()`,
);
} else {
// Objective-C
if (config.modResults.contents.includes('[RNSentrySDK start]')) {
warnOnce(`Your '${fileName}' already contains '[RNSentrySDK start]'.`);
return config;
}
if (!config.modResults.contents.includes('#import <RNSentry/RNSentry.h>')) {
// Add import after AppDelegate.h
config.modResults.contents = config.modResults.contents.replace(
/(#import "AppDelegate.h"\n)/,
`$1#import <RNSentry/RNSentry.h>\n`,
);
}
// Add [RNSentrySDK start] at the beginning of application:didFinishLaunchingWithOptions method
config.modResults.contents = config.modResults.contents.replace(
/(- \(BOOL\)application:[\s\S]*?didFinishLaunchingWithOptions:[\s\S]*?\{\n)(\s*)/s,
`$1$2[RNSentrySDK start];\n$2`,
);
}

return config;
});
}
177 changes: 177 additions & 0 deletions packages/core/test/expo-plugin/modifyAppDelegate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import type { ExpoConfig } from '@expo/config-types';

import { warnOnce } from '../../plugin/src/utils';
import { modifyAppDelegate } from '../../plugin/src/withSentryIOS';

// Mock dependencies
jest.mock('@expo/config-plugins', () => ({
...jest.requireActual('@expo/config-plugins'),
withAppDelegate: jest.fn((config, callback) => callback(config)),
}));

jest.mock('../../plugin/src/utils', () => ({
warnOnce: jest.fn(),
}));

interface MockedExpoConfig extends ExpoConfig {
modResults: {
path: string;
contents: string;
language: 'swift' | 'objc';
};
}

const objcContents = `#import "AppDelegate.h"
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
self.moduleName = @"main";
// You can add your custom initial props in the dictionary below.
// They will be passed down to the ViewController used by React Native.
self.initialProps = @{};
return [super application:application didFinishLaunchingWithOptions:launchOptions];
}
@end
`;

const objcExpected = `#import "AppDelegate.h"
#import <RNSentry/RNSentry.h>
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
[RNSentrySDK start];
self.moduleName = @"main";
// You can add your custom initial props in the dictionary below.
// They will be passed down to the ViewController used by React Native.
self.initialProps = @{};
return [super application:application didFinishLaunchingWithOptions:launchOptions];
}
@end
`;

const swiftContents = `import React
import React_RCTAppDelegate
import ReactAppDependencyProvider
import UIKit
@main
class AppDelegate: RCTAppDelegate {
override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
self.moduleName = "sentry-react-native-sample"
self.dependencyProvider = RCTAppDependencyProvider()
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}`;

const swiftExpected = `import React
import React_RCTAppDelegate
import ReactAppDependencyProvider
import UIKit
import RNSentry
@main
class AppDelegate: RCTAppDelegate {
override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
RNSentrySDK.start()
self.moduleName = "sentry-react-native-sample"
self.dependencyProvider = RCTAppDependencyProvider()
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}`;

describe('modifyAppDelegate', () => {
let config: MockedExpoConfig;

beforeEach(() => {
jest.clearAllMocks();
// Reset to a mocked Swift config after each test
config = {
name: 'test',
slug: 'test',
modResults: {
path: 'samples/react-native/ios/AppDelegate.swift',
contents: swiftContents,
language: 'swift',
},
};
});

it('should skip modification if modResults or path is missing', async () => {
config.modResults.path = undefined;

const result = await modifyAppDelegate(config);

expect(warnOnce).toHaveBeenCalledWith('Skipping AppDelegate modification because the file does not exist.');
expect(result).toBe(config); // No modification
});

it('should warn if RNSentrySDK.start() is already present in a Swift project', async () => {
config.modResults.contents = 'RNSentrySDK.start();';

const result = await modifyAppDelegate(config);

expect(warnOnce).toHaveBeenCalledWith(`Your 'AppDelegate.swift' already contains 'RNSentrySDK.start()'.`);
expect(result).toBe(config); // No modification
});

it('should warn if [RNSentrySDK start] is already present in an Objective-C project', async () => {
config.modResults.language = 'objc';
config.modResults.path = 'samples/react-native/ios/AppDelegate.mm';
config.modResults.contents = '[RNSentrySDK start];';

const result = await modifyAppDelegate(config);

expect(warnOnce).toHaveBeenCalledWith(`Your 'AppDelegate.mm' already contains '[RNSentrySDK start]'.`);
expect(result).toBe(config); // No modification
});

it('should modify a Swift file by adding the RNSentrySDK import and start', async () => {
const result = (await modifyAppDelegate(config)) as MockedExpoConfig;

expect(result.modResults.contents).toContain('import RNSentry');
expect(result.modResults.contents).toContain('RNSentrySDK.start()');
expect(result.modResults.contents).toBe(swiftExpected);
});

it('should modify an Objective-C file by adding the RNSentrySDK import and start', async () => {
config.modResults.language = 'objc';
config.modResults.contents = objcContents;

const result = (await modifyAppDelegate(config)) as MockedExpoConfig;

expect(result.modResults.contents).toContain('#import <RNSentry/RNSentry.h>');
expect(result.modResults.contents).toContain('[RNSentrySDK start];');
expect(result.modResults.contents).toBe(objcExpected);
});

it('should insert import statements only once in an Swift project', async () => {
config.modResults.contents =
'import UIKit\nimport RNSentrySDK\n\noverride func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {';

const result = (await modifyAppDelegate(config)) as MockedExpoConfig;

const importCount = (result.modResults.contents.match(/import RNSentrySDK/g) || []).length;
expect(importCount).toBe(1);
});

it('should insert import statements only once in an Objective-C project', async () => {
config.modResults.language = 'objc';
config.modResults.contents =
'#import "AppDelegate.h"\n#import <RNSentry/RNSentry.h>\n\n- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {';

const result = (await modifyAppDelegate(config)) as MockedExpoConfig;

const importCount = (result.modResults.contents.match(/#import <RNSentry\/RNSentry.h>/g) || []).length;
expect(importCount).toBe(1);
});
});
Loading
Loading