Skip to content
Draft
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

### Fixes

- Show the feedback form above native-stack modals on iOS via the experimental `_experiments.presentOverNativeModals` feedback option ([#6882](https://github.com/getsentry/sentry-react-native/pull/6882))
- Do not mark `sendDefaultPii` as deprecated in the React Native options ([#6864](https://github.com/getsentry/sentry-react-native/pull/6864))

### Dependencies
Expand Down
6 changes: 6 additions & 0 deletions packages/core/etc/sentry-react-native.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,11 @@ export interface FeedbackCallbacks {
onSubmitSuccess: (data: FeedbackFormData) => void;
}

// @public
export type FeedbackExperiments = {
presentOverNativeModals?: boolean;
};

// Warning: (ae-forgotten-export) The symbol "FeedbackFormState" needs to be exported by the entry point index.d.ts
//
// @beta
Expand Down Expand Up @@ -534,6 +539,7 @@ export const feedbackIntegration: (initOptions?: Partial<FeedbackFormProps> & {
themeLight?: Partial<FeedbackFormTheme>;
themeDark?: Partial<FeedbackFormTheme>;
enableShakeToReport?: boolean;
_experiments?: FeedbackExperiments;
}) => FeedbackIntegration;

// @public
Expand Down
6 changes: 5 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@
"open": "*",
"promise": "*",
"react": ">=17.0.0",
"react-native": ">=0.65.0"
"react-native": ">=0.65.0",
"react-native-screens": "*"
},
"dependencies": {
"@sentry/browser": "10.76.2",
Expand Down Expand Up @@ -199,6 +200,9 @@
},
"promise": {
"optional": true
},
"react-native-screens": {
"optional": true
}
}
}
90 changes: 72 additions & 18 deletions packages/core/src/js/feedback/FeedbackFormProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@ import {
showFeedbackForm,
SLIDE_ANIMATION_DURATION,
} from './FeedbackFormManager';
import { getFullWindowOverlay } from './fullWindowOverlay';
import {
getFeedbackButtonOptions,
getFeedbackOptions,
getScreenshotButtonOptions,
isPresentOverNativeModalsEnabled,
Comment thread
sentry-warden[bot] marked this conversation as resolved.
isShakeToReportEnabled,
} from './integration';
import { lazyLoadShakeToReportIntegration } from './lazy';
Expand Down Expand Up @@ -83,6 +85,7 @@ export class FeedbackFormProvider extends React.Component<FeedbackFormProviderPr
};

private _themeListener: NativeEventSubscription | undefined;
private _dimensionsListener: { remove: () => void } | undefined;
private _startedShakeListener: boolean = false;

private _panResponder = PanResponder.create({
Expand Down Expand Up @@ -132,10 +135,24 @@ export class FeedbackFormProvider extends React.Component<FeedbackFormProviderPr
this.forceUpdate();
});

// When the form is rendered in a `FullWindowOverlay` its size comes from
// `Dimensions.get('window')` at render time, so re-render on rotation/resize
// to keep it full-screen. (No-op for the `Modal` path, which auto-fills.)
this._dimensionsListener = Dimensions.addEventListener('change', () => {
this.forceUpdate();
});

if (isShakeToReportEnabled()) {
lazyLoadShakeToReportIntegration();
this._startedShakeListener = startShakeListener(showFeedbackForm);
}

if (Platform.OS === 'ios' && isPresentOverNativeModalsEnabled() && !getFullWindowOverlay()) {
debug.warn(
"[Feedback] '_experiments.presentOverNativeModals' is enabled but 'react-native-screens' is not available; " +
'falling back to Modal. The form may not appear above presented native-stack sheets.',
);
}
}

/**
Expand All @@ -146,6 +163,10 @@ export class FeedbackFormProvider extends React.Component<FeedbackFormProviderPr
this._themeListener.remove();
}

if (this._dimensionsListener) {
this._dimensionsListener.remove();
}

if (this._startedShakeListener) {
stopShakeListener();
}
Expand Down Expand Up @@ -196,6 +217,56 @@ export class FeedbackFormProvider extends React.Component<FeedbackFormProviderPr
outputRange: ['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0.9)'],
});

const sheet = (
<>
<View style={topSpacer} />
<Animated.View
style={[modalSheetContainer(theme), { transform: [{ translateY: this.state.panY }] }]}
{...this._panResponder.panHandlers}
>
<ScrollView
bounces={false}
keyboardShouldPersistTaps="handled"
automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'}
onScroll={this._handleScroll}
>
<FeedbackForm
{...getFeedbackOptions()}
onFormClose={this._handleClose}
onFormSubmitted={this._handleClose}
/>
</ScrollView>
</Animated.View>
</>
);

// On iOS, a React Native `Modal` presents a view controller from the root VC's
// presentation chain. When a native-stack screen is already presented (e.g. a
// `formSheet`/`modal`), UIKit refuses the second presentation and the form never
// shows. `FullWindowOverlay` from react-native-screens renders above everything
// in the key window โ€” including presented sheets โ€” so the form shows.
// Opt-in via `_experiments.presentOverNativeModals`; falls back to `Modal` when
// disabled or when react-native-screens isn't installed.
const FullWindowOverlay =
Platform.OS === 'ios' && isPresentOverNativeModalsEnabled() ? getFullWindowOverlay() : undefined;
if (FullWindowOverlay) {
const { width, height } = Dimensions.get('window');
return (
<>
{this.props.children}
{isButtonVisible && <FeedbackButton {...getFeedbackButtonOptions()} />}
{isScreenshotButtonVisible && <ScreenshotButton {...getScreenshotButtonOptions()} />}
{isVisible && (
<FullWindowOverlay>
<Animated.View style={[{ width, height }, { backgroundColor }]} testID="feedback-form-overlay">
Comment thread
cursor[bot] marked this conversation as resolved.
{sheet}
</Animated.View>
</FullWindowOverlay>
Comment thread
cursor[bot] marked this conversation as resolved.
)}
</>
);
}

// Wrapping the `Modal` component in a `View` component is necessary to avoid
// issues like https://github.com/software-mansion/react-native-reanimated/issues/6035
return (
Expand All @@ -212,24 +283,7 @@ export class FeedbackFormProvider extends React.Component<FeedbackFormProviderPr
onRequestClose={this._handleClose}
testID="feedback-form-modal"
>
<View style={topSpacer} />
<Animated.View
style={[modalSheetContainer(theme), { transform: [{ translateY: this.state.panY }] }]}
{...this._panResponder.panHandlers}
>
<ScrollView
bounces={false}
keyboardShouldPersistTaps="handled"
automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'}
onScroll={this._handleScroll}
>
<FeedbackForm
{...getFeedbackOptions()}
onFormClose={this._handleClose}
onFormSubmitted={this._handleClose}
/>
</ScrollView>
</Animated.View>
{sheet}
</Modal>
</Animated.View>
)}
Expand Down
46 changes: 46 additions & 0 deletions packages/core/src/js/feedback/fullWindowOverlay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type * as React from 'react';

type FullWindowOverlayComponent = React.ComponentType<{ children?: React.ReactNode }>;

let _resolved = false;
let _component: FullWindowOverlayComponent | undefined;

/**
* Lazily resolves `FullWindowOverlay` from `react-native-screens`, if installed.
*
* On iOS, React Native's `Modal` presents a view controller from the root view
* controller's chain. When a native-stack screen is already presented (e.g. a
* `formSheet`/`modal`), UIKit refuses the second presentation and the feedback
* form silently fails to appear. `FullWindowOverlay` renders above everything in
* the key window โ€” including presented sheets โ€” so the form shows regardless.
*
* `react-native-screens` is an optional dependency: the `require` is wrapped in a
* try/catch, which Metro treats as an optional import, so bundling still works
* when it isn't installed. In that case callers fall back to `Modal`.
*/
export function getFullWindowOverlay(): FullWindowOverlayComponent | undefined {
if (_resolved) {
return _component;
}
_resolved = true;

try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const screens = require('react-native-screens') as { FullWindowOverlay?: FullWindowOverlayComponent };
if (screens?.FullWindowOverlay) {
_component = screens.FullWindowOverlay;
}
} catch {
// react-native-screens is not installed โ€” callers fall back to Modal.
}
Comment thread
sentry-warden[bot] marked this conversation as resolved.

return _component;
}

/**
* For testing purposes only.
*/
export function resetFullWindowOverlayCache(): void {
_resolved = false;
_component = undefined;
}
38 changes: 38 additions & 0 deletions packages/core/src/js/feedback/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,31 @@ import type { FeedbackButtonProps, FeedbackFormProps, ScreenshotButtonProps } fr

export const MOBILE_FEEDBACK_INTEGRATION_NAME = 'MobileFeedback';

/**
* Experimental feedback options. These are unstable and may change or be
* removed without a major version bump, and some may become the default.
*/
export type FeedbackExperiments = {
/**
* iOS only. Render the feedback form inside react-native-screens'
* `FullWindowOverlay` instead of a React Native `Modal`, so it can appear
* above a presented native-stack sheet/modal (e.g. `presentation: 'formSheet'`).
*
* Requires `react-native-screens`; no-ops (falls back to `Modal`) when it is
* not installed.
*
* Known limitation: because the form is drawn in a window-level overlay, the
* screenshot attachment flows are limited over a presented native-stack sheet.
* The photo-library picker ("Add a screenshot", via `imagePicker`) is presented
* behind the overlay, and "Take a screenshot" reveals its capture button in the
* root view tree behind the sheet โ€” both unreachable while the sheet is up.
* Alerts and text entry are unaffected.
*
* @default false
*/
presentOverNativeModals?: boolean;
};

type FeedbackIntegration = Integration & {
options: Partial<FeedbackFormProps>;
buttonOptions: Partial<FeedbackButtonProps>;
Expand All @@ -13,6 +38,7 @@ type FeedbackIntegration = Integration & {
themeLight: Partial<FeedbackFormTheme>;
themeDark: Partial<FeedbackFormTheme>;
enableShakeToReport: boolean;
experiments: FeedbackExperiments;
};

export const feedbackIntegration = (
Expand All @@ -31,6 +57,11 @@ export const feedbackIntegration = (
* @default false
*/
enableShakeToReport?: boolean;
/**
* Experimental feedback options. Unstable โ€” may change or be removed without
* a major version bump.
*/
_experiments?: FeedbackExperiments;
} = {},
): FeedbackIntegration => {
const {
Expand All @@ -40,6 +71,7 @@ export const feedbackIntegration = (
themeLight: lightTheme,
themeDark: darkTheme,
enableShakeToReport: shakeToReport,
_experiments: experiments,
...widgetOptions
} = initOptions;

Expand All @@ -52,6 +84,7 @@ export const feedbackIntegration = (
themeLight: lightTheme || {},
themeDark: darkTheme || {},
enableShakeToReport: shakeToReport || false,
experiments: experiments || {},
};
};

Expand Down Expand Up @@ -117,3 +150,8 @@ export const isShakeToReportEnabled = (): boolean => {
const integration = _getClientIntegration();
return integration?.enableShakeToReport ?? false;
};

export const isPresentOverNativeModalsEnabled = (): boolean => {
const integration = _getClientIntegration();
return integration?.experiments.presentOverNativeModals ?? false;
};
1 change: 1 addition & 0 deletions packages/core/src/js/integrations/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export { expoUpdatesListenerIntegration } from './expoupdateslistener';
export { spotlightIntegration } from './spotlight';
export { mobileReplayIntegration } from '../replay/mobilereplay';
export { feedbackIntegration } from '../feedback/integration';
export type { FeedbackExperiments } from '../feedback/integration';
export { browserReplayIntegration } from '../replay/browserReplay';
export { appStartIntegration } from '../tracing/integrations/appStart';
export { nativeFramesIntegration, createNativeFramesIntegrations } from '../tracing/integrations/nativeFrames';
Expand Down
Loading
Loading