Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
9bc0c17
feat(feedback): Show feedback widget on device shake
antonis Feb 26, 2026
c5d398e
Add to sample app
antonis Feb 26, 2026
3cb51f5
Merge branch 'main' into antonis/feedback-shake
antonis Feb 26, 2026
3e37fe4
Merge branch 'main' into antonis/feedback-shake
antonis Feb 27, 2026
5271319
Merge branch 'main' into antonis/feedback-shake
antonis Feb 27, 2026
96664e8
fix(ios): fix shake detection in iOS simulator by swizzling UIApplica…
antonis Feb 27, 2026
58b9f2b
Merge branch 'main' into antonis/feedback-shake
antonis Feb 27, 2026
a027843
fix(ios): switch shake detection to UIWindow.motionEnded:withEvent: s…
antonis Feb 27, 2026
6054e19
test(sample): add FeedbackWidgetProvider to React Native sample app
antonis Feb 27, 2026
6d13f6a
Revert "test(sample): add FeedbackWidgetProvider to React Native samp…
antonis Feb 27, 2026
bf89f61
fix(ios): explicitly enable shake detection in addListener like Android
antonis Feb 27, 2026
128bd3e
debug(ios): add NSLog tracing to shake detection chain
antonis Feb 27, 2026
db0e082
Merge branch 'main' into antonis/feedback-shake
antonis Mar 2, 2026
ea484a2
Merge branch 'main' into antonis/feedback-shake
antonis Mar 2, 2026
5cb9033
fix(ios): add @import Sentry so SENTRY_HAS_UIKIT is defined
antonis Mar 2, 2026
023bab7
fix(ios): use TARGET_OS_IOS instead of SENTRY_HAS_UIKIT
antonis Mar 2, 2026
3e473ae
Merge branch 'main' into antonis/feedback-shake
antonis Mar 3, 2026
e844405
fix(ios): use explicit enableShakeDetection method for iOS shake-to-r…
antonis Mar 3, 2026
d725799
fix(ios): fix shake detection crash and swizzle safety
antonis Mar 3, 2026
5c048e0
Merge branch 'main' into antonis/feedback-shake
antonis Mar 3, 2026
27867aa
refactor: replace RNSentryShakeDetector with SentryShakeDetector from…
antonis Mar 3, 2026
daf949d
Merge branch 'main' into antonis/feedback-shake-native
antonis Mar 4, 2026
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

### Features

- Show feedback widget on device shake ([#5729](https://github.com/getsentry/sentry-react-native/pull/5729))
- Use `Sentry.showFeedbackOnShake()` / `Sentry.hideFeedbackOnShake()` or set `feedbackIntegration({ enableShakeToReport: true })`
- Add expo constants on event context ([#5748](https://github.com/getsentry/sentry-react-native/pull/5748))
- Capture dynamic route params as span attributes for Expo Router navigations ([#5750](https://github.com/getsentry/sentry-react-native/pull/5750))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1014,7 +1014,7 @@ - (void)testCreateUserWithPartialGeoDataCreatesSentryGeoObject
NSDictionary *userKeys =
@{ @"id" : @"456", @"geo" : @ { @"city" : @"New York", @"country_code" : @"US" } };

NSDictionary *userDataKeys = @{};
NSDictionary *userDataKeys = @{ };

SentryUser *user = [RNSentry userFrom:userKeys otherUserKeys:userDataKeys];

Expand All @@ -1031,9 +1031,9 @@ - (void)testCreateUserWithPartialGeoDataCreatesSentryGeoObject

- (void)testCreateUserWithEmptyGeoDataCreatesSentryGeoObject
{
NSDictionary *userKeys = @{ @"id" : @"789", @"geo" : @ {} };
NSDictionary *userKeys = @{ @"id" : @"789", @"geo" : @ { } };

NSDictionary *userDataKeys = @{};
NSDictionary *userDataKeys = @{ };

SentryUser *user = [RNSentry userFrom:userKeys otherUserKeys:userDataKeys];

Expand All @@ -1052,7 +1052,7 @@ - (void)testCreateUserWithoutGeoDataDoesNotCreateGeoObject
{
NSDictionary *userKeys = @{ @"id" : @"999", @"email" : @"test@example.com" };

NSDictionary *userDataKeys = @{};
NSDictionary *userDataKeys = @{ };

SentryUser *user = [RNSentry userFrom:userKeys otherUserKeys:userDataKeys];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ - (void)testNullUser
- (void)testEmptyUser
{
SentryUser *expected = [[SentryUser alloc] init];
[expected setData:@{}];
[expected setData:@{ }];

SentryUser *actual = [RNSentry userFrom:@{} otherUserKeys:@{}];
SentryUser *actual = [RNSentry userFrom:@{ } otherUserKeys:@{ }];
XCTAssertTrue([actual isEqualToUser:expected]);
}

Expand All @@ -63,9 +63,9 @@ - (void)testInvalidUser

SentryUser *actual = [RNSentry userFrom:@{
@"id" : @123,
@"ip_address" : @ {},
@"email" : @ {},
@"username" : @ {},
@"ip_address" : @ { },
@"email" : @ { },
@"username" : @ { },
}
otherUserKeys:nil];

Expand All @@ -79,9 +79,9 @@ - (void)testPartiallyInvalidUser

SentryUser *actual = [RNSentry userFrom:@{
@"id" : @"123",
@"ip_address" : @ {},
@"email" : @ {},
@"username" : @ {},
@"ip_address" : @ { },
@"email" : @ { },
@"username" : @ { },
}
otherUserKeys:nil];

Expand Down Expand Up @@ -156,7 +156,7 @@ - (void)testUserWithEmptyGeo
SentryGeo *expectedGeo = [SentryGeo alloc];
[expected setGeo:expectedGeo];

SentryUser *actual = [RNSentry userFrom:@{ @"id" : @"123", @"geo" : @ {} } otherUserKeys:nil];
SentryUser *actual = [RNSentry userFrom:@{ @"id" : @"123", @"geo" : @ { } } otherUserKeys:nil];

XCTAssertTrue([actual isEqualToUser:expected]);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
import io.sentry.android.core.InternalSentrySdk;
import io.sentry.android.core.SentryAndroidDateProvider;
import io.sentry.android.core.SentryAndroidOptions;
import io.sentry.android.core.SentryShakeDetector;
import io.sentry.android.core.ViewHierarchyEventProcessor;
import io.sentry.android.core.internal.debugmeta.AssetsDebugMetaLoader;
import io.sentry.android.core.internal.util.SentryFrameMetricsCollector;
Expand Down Expand Up @@ -122,6 +123,10 @@ public class RNSentryModuleImpl {

private final @NotNull Runnable emitNewFrameEvent;

private static final String ON_SHAKE_EVENT = "rn_sentry_on_shake";
private @Nullable SentryShakeDetector shakeDetector;
private int shakeListenerCount = 0;

/** Max trace file size in bytes. */
private long maxTraceFileSize = 5 * 1024 * 1024;

Expand Down Expand Up @@ -192,16 +197,61 @@ public void crash() {
}

public void addListener(String eventType) {
if (ON_SHAKE_EVENT.equals(eventType)) {
shakeListenerCount++;
if (shakeListenerCount == 1) {
startShakeDetection();
}
return;
}
// Is must be defined otherwise the generated interface from TS won't be
// fulfilled
logger.log(SentryLevel.ERROR, "addListener of NativeEventEmitter can't be used on Android!");
}

public void removeListeners(double id) {
// Is must be defined otherwise the generated interface from TS won't be
// fulfilled
logger.log(
SentryLevel.ERROR, "removeListeners of NativeEventEmitter can't be used on Android!");
shakeListenerCount = Math.max(0, shakeListenerCount - (int) id);
if (shakeListenerCount == 0) {
stopShakeDetection();
}
}

private void startShakeDetection() {
if (shakeDetector != null) {
return;
}

final ReactApplicationContext context = getReactApplicationContext();
shakeDetector = new SentryShakeDetector(logger);
shakeDetector.start(
context,
() -> {
final ReactApplicationContext ctx = getReactApplicationContext();
if (ctx.hasActiveReactInstance()) {
ctx.getJSModule(
com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
.class)
.emit(ON_SHAKE_EVENT, null);
}
});
}

private void stopShakeDetection() {
if (shakeDetector != null) {
shakeDetector.stop();
shakeDetector = null;
}
}

public void enableShakeDetection() {
// On Android, shake detection is started via addListener. This method is a no-op
// because it exists to satisfy the cross-platform spec (on iOS, the NativeEventEmitter
// addListener does not reliably dispatch to native, so an explicit call is needed).
}

public void disableShakeDetection() {
// On Android, shake detection is stopped via removeListeners. This method is a no-op
// for the same reason as enableShakeDetection.
}

public void fetchModules(Promise promise) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,4 +212,14 @@ public void popTimeToDisplayFor(String key, Promise promise) {
public boolean setActiveSpanId(String spanId) {
return this.impl.setActiveSpanId(spanId);
}

@Override
public void enableShakeDetection() {
this.impl.enableShakeDetection();
}

@Override
public void disableShakeDetection() {
this.impl.disableShakeDetection();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -212,4 +212,14 @@ public void popTimeToDisplayFor(String key, Promise promise) {
public boolean setActiveSpanId(String spanId) {
return this.impl.setActiveSpanId(spanId);
}

@ReactMethod
public void enableShakeDetection() {
this.impl.enableShakeDetection();
}

@ReactMethod
public void disableShakeDetection() {
this.impl.disableShakeDetection();
}
}
36 changes: 35 additions & 1 deletion packages/core/ios/RNSentry.mm
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@

#import "RNSentryDependencyContainer.h"
#import "RNSentryEvents.h"
#import <Sentry/SentryShakeDetector.h>

#if SENTRY_TARGET_REPLAY_SUPPORTED
# import "RNSentryReplay.h"
Expand Down Expand Up @@ -292,9 +293,42 @@ - (void)stopObserving
hasListeners = NO;
}

- (void)handleShakeDetected
{
if (hasListeners) {
[self sendEventWithName:RNSentryOnShakeEvent body:@{ }];
}
}

// Explicit method to start shake detection.
// NativeEventEmitter.addListener does not reliably dispatch to native addListener: on iOS
// with New Architecture (TurboModules), so we expose explicit enable/disable methods
// that JS calls directly from startShakeListener/stopShakeListener.
RCT_EXPORT_METHOD(enableShakeDetection)
{
// Remove any existing observer first to avoid duplicate notifications
[[NSNotificationCenter defaultCenter] removeObserver:self
name:SentryShakeDetectedNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(handleShakeDetected)
name:SentryShakeDetectedNotification
object:nil];
[SentryShakeDetector enable];
hasListeners = YES;
}

RCT_EXPORT_METHOD(disableShakeDetection)
{
[SentryShakeDetector disable];
[[NSNotificationCenter defaultCenter] removeObserver:self
name:SentryShakeDetectedNotification
object:nil];
}

- (NSArray<NSString *> *)supportedEvents
{
return @[ RNSentryNewFrameEvent ];
return @[ RNSentryNewFrameEvent, RNSentryOnShakeEvent ];
}

RCT_EXPORT_METHOD(
Expand Down
1 change: 1 addition & 0 deletions packages/core/ios/RNSentryEvents.h
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#import <Foundation/Foundation.h>

extern NSString *const RNSentryNewFrameEvent;
extern NSString *const RNSentryOnShakeEvent;
1 change: 1 addition & 0 deletions packages/core/ios/RNSentryEvents.m
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#import "RNSentryEvents.h"

NSString *const RNSentryNewFrameEvent = @"rn_sentry_new_frame";
NSString *const RNSentryOnShakeEvent = @"rn_sentry_on_shake";
2 changes: 1 addition & 1 deletion packages/core/ios/RNSentryReplay.mm
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ + (BOOL)updateOptions:(NSMutableDictionary *)options
}

NSLog(@"Setting up session replay");
NSDictionary *replayOptions = options[@"mobileReplayOptions"] ?: @{};
NSDictionary *replayOptions = options[@"mobileReplayOptions"] ?: @{ };

NSString *qualityString = options[@"replaysSessionQuality"];

Expand Down
2 changes: 1 addition & 1 deletion packages/core/ios/RNSentrySDK.m
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ + (void)start:(NSString *)path configureOptions:(void (^)(SentryOptions *options
if (options == nil) {
// Fallback in case that options file could not be parsed.
NSError *fallbackError = nil;
options = [PrivateSentrySDKOnly optionsWithDictionary:@{} didFailWithError:&fallbackError];
options = [PrivateSentrySDKOnly optionsWithDictionary:@{ } didFailWithError:&fallbackError];
if (fallbackError != nil) {
NSLog(@"[RNSentry] Failed to create fallback options with error: %@",
fallbackError.localizedDescription);
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/js/NativeRNSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export interface Spec extends TurboModule {
popTimeToDisplayFor(key: string): Promise<number | undefined | null>;
setActiveSpanId(spanId: string): boolean;
encodeToBase64(data: number[]): Promise<string | undefined | null>;
enableShakeDetection(): void;
disableShakeDetection(): void;
}

export type NativeStackFrame = {
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/js/feedback/FeedbackWidgetManager.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { debug } from '@sentry/core';
import { isWeb } from '../utils/environment';
import { lazyLoadAutoInjectFeedbackButtonIntegration,lazyLoadAutoInjectFeedbackIntegration, lazyLoadAutoInjectScreenshotButtonIntegration } from './lazy';
import { startShakeListener, stopShakeListener } from './ShakeToReportBug';

export const PULL_DOWN_CLOSE_THRESHOLD = 200;
export const SLIDE_ANIMATION_DURATION = 200;
Expand Down Expand Up @@ -132,4 +133,13 @@ const resetScreenshotButtonManager = (): void => {
ScreenshotButtonManager.reset();
};

export { showFeedbackButton, hideFeedbackButton, showFeedbackWidget, showScreenshotButton, hideScreenshotButton, resetFeedbackButtonManager, resetFeedbackWidgetManager, resetScreenshotButtonManager };
const showFeedbackOnShake = (): void => {
lazyLoadAutoInjectFeedbackIntegration();
startShakeListener(showFeedbackWidget);
};

const hideFeedbackOnShake = (): void => {
stopShakeListener();
};

export { showFeedbackButton, hideFeedbackButton, showFeedbackWidget, showFeedbackOnShake, hideFeedbackOnShake, showScreenshotButton, hideScreenshotButton, resetFeedbackButtonManager, resetFeedbackWidgetManager, resetScreenshotButtonManager };
14 changes: 11 additions & 3 deletions packages/core/src/js/feedback/FeedbackWidgetProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ import {
FeedbackWidgetManager,
PULL_DOWN_CLOSE_THRESHOLD,
ScreenshotButtonManager,
showFeedbackWidget,
SLIDE_ANIMATION_DURATION,
} from './FeedbackWidgetManager';
import { getFeedbackButtonOptions, getFeedbackOptions, getScreenshotButtonOptions } from './integration';
import { getFeedbackButtonOptions, getFeedbackOptions, getScreenshotButtonOptions, isShakeToReportEnabled } from './integration';
import { ScreenshotButton } from './ScreenshotButton';
import { startShakeListener, stopShakeListener } from './ShakeToReportBug';
import { isModalSupported, isNativeDriverSupportedForColorAnimations } from './utils';

const useNativeDriverForColorAnimations = isNativeDriverSupportedForColorAnimations();
Expand Down Expand Up @@ -92,21 +94,27 @@ export class FeedbackWidgetProvider extends React.Component<FeedbackWidgetProvid
}

/**
* Add a listener to the theme change event.
* Add a listener to the theme change event and start shake detection if configured.
*/
public componentDidMount(): void {
this._themeListener = Appearance.addChangeListener(() => {
this.forceUpdate();
});

if (isShakeToReportEnabled()) {
startShakeListener(showFeedbackWidget);
}
}

/**
* Clean up the theme listener.
* Clean up the theme listener and stop shake detection.
*/
public componentWillUnmount(): void {
if (this._themeListener) {
this._themeListener.remove();
}

stopShakeListener();
}

/**
Expand Down
Loading
Loading