Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
40 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
59d3752
Merge branch 'main' into antonis/feedback-shake-native
antonis Mar 5, 2026
725ceaf
fix(ios): adapt to SentryShakeDetector refactored to Swift in sentry-…
antonis Mar 5, 2026
04c1bd4
Merge branch 'main' into antonis/feedback-shake-native
antonis Mar 19, 2026
8c44ee5
Reverse unneeded objc changes
antonis Mar 19, 2026
fcdc83c
Reverse obj change
antonis Mar 19, 2026
cfcb5c7
Rename methods for clarity
antonis Mar 19, 2026
33a5918
fix(android,ios): Fix shake detection lifecycle bugs
antonis Mar 19, 2026
a7b836f
Update changelog
antonis Mar 19, 2026
259687a
fix(ios): Use dedicated flag for shake event emission
antonis Mar 19, 2026
6a1db5e
fix(feedback): Guard against shake listener crashes and asymmetric li…
antonis Mar 19, 2026
f88f95c
fix(feedback): Remove subscription on shake listener start failure
antonis Mar 19, 2026
ca15aff
fix(android): Wrap shake detection in try/catch to prevent host app c…
antonis Mar 19, 2026
bc93b34
fix(feedback): Prevent provider from stopping externally-owned shake …
antonis Mar 19, 2026
d8fbd8c
Lint fix
antonis Mar 19, 2026
99f2507
fix(android): Wrap shake detection callback in try/catch
antonis Mar 19, 2026
3f73070
Merge branch 'main' into antonis/feedback-shake-native
antonis Mar 19, 2026
1d515e7
fix(feedback): Track ownership in imperative shake API
antonis Mar 19, 2026
e7996b8
fix(feedback): Guard against shake listener crashes and asymmetric li…
antonis Mar 19, 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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@

## Unreleased

### Features

- Show feedback widget on device shake ([#5729](https://github.com/getsentry/sentry-react-native/pull/5729))
Comment thread
antonis marked this conversation as resolved.
Outdated
- Use `Sentry.showFeedbackOnShake()` / `Sentry.hideFeedbackOnShake()` or set `feedbackIntegration({ enableShakeToReport: true })`

### Fixes

- Resolve relative `SOURCEMAP_FILE` paths against the project root in the Xcode build script ([#5730](https://github.com/getsentry/sentry-react-native/pull/5730))
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();
}
}
Comment thread
antonis marked this conversation as resolved.

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);
}
});
}
Comment thread
cursor[bot] marked this conversation as resolved.

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:@{ }];
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

// 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;
Comment thread
antonis marked this conversation as resolved.
Outdated
}

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();
};
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

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();
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}

/**
Expand Down
Loading
Loading