diff --git a/CHANGELOG.md b/CHANGELOG.md index 6830378a55..7bd740a1a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index f666d3121e..3bcfbd9036 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -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 @@ -534,6 +539,7 @@ export const feedbackIntegration: (initOptions?: Partial & { themeLight?: Partial; themeDark?: Partial; enableShakeToReport?: boolean; + _experiments?: FeedbackExperiments; }) => FeedbackIntegration; // @public diff --git a/packages/core/package.json b/packages/core/package.json index cad71793a2..2533402470 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", @@ -199,6 +200,9 @@ }, "promise": { "optional": true + }, + "react-native-screens": { + "optional": true } } } diff --git a/packages/core/src/js/feedback/FeedbackFormProvider.tsx b/packages/core/src/js/feedback/FeedbackFormProvider.tsx index ac4ad45cd2..181c6dc65a 100644 --- a/packages/core/src/js/feedback/FeedbackFormProvider.tsx +++ b/packages/core/src/js/feedback/FeedbackFormProvider.tsx @@ -31,10 +31,12 @@ import { showFeedbackForm, SLIDE_ANIMATION_DURATION, } from './FeedbackFormManager'; +import { getFullWindowOverlay } from './fullWindowOverlay'; import { getFeedbackButtonOptions, getFeedbackOptions, getScreenshotButtonOptions, + isPresentOverNativeModalsEnabled, isShakeToReportEnabled, } from './integration'; import { lazyLoadShakeToReportIntegration } from './lazy'; @@ -83,6 +85,7 @@ export class FeedbackFormProvider extends React.Component void } | undefined; private _startedShakeListener: boolean = false; private _panResponder = PanResponder.create({ @@ -132,10 +135,24 @@ export class FeedbackFormProvider extends React.Component { + 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.', + ); + } } /** @@ -146,6 +163,10 @@ export class FeedbackFormProvider extends React.Component + + + + + + + + ); + + // 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 && } + {isScreenshotButtonVisible && } + {isVisible && ( + + + {sheet} + + + )} + + ); + } + // 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 ( @@ -212,24 +283,7 @@ export class FeedbackFormProvider extends React.Component - - - - - - + {sheet} )} diff --git a/packages/core/src/js/feedback/fullWindowOverlay.ts b/packages/core/src/js/feedback/fullWindowOverlay.ts new file mode 100644 index 0000000000..8a30cc1118 --- /dev/null +++ b/packages/core/src/js/feedback/fullWindowOverlay.ts @@ -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. + } + + return _component; +} + +/** + * For testing purposes only. + */ +export function resetFullWindowOverlayCache(): void { + _resolved = false; + _component = undefined; +} diff --git a/packages/core/src/js/feedback/integration.ts b/packages/core/src/js/feedback/integration.ts index 9f95ca4eb6..cfe0edda1b 100644 --- a/packages/core/src/js/feedback/integration.ts +++ b/packages/core/src/js/feedback/integration.ts @@ -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; buttonOptions: Partial; @@ -13,6 +38,7 @@ type FeedbackIntegration = Integration & { themeLight: Partial; themeDark: Partial; enableShakeToReport: boolean; + experiments: FeedbackExperiments; }; export const feedbackIntegration = ( @@ -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 { @@ -40,6 +71,7 @@ export const feedbackIntegration = ( themeLight: lightTheme, themeDark: darkTheme, enableShakeToReport: shakeToReport, + _experiments: experiments, ...widgetOptions } = initOptions; @@ -52,6 +84,7 @@ export const feedbackIntegration = ( themeLight: lightTheme || {}, themeDark: darkTheme || {}, enableShakeToReport: shakeToReport || false, + experiments: experiments || {}, }; }; @@ -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; +}; diff --git a/packages/core/src/js/integrations/exports.ts b/packages/core/src/js/integrations/exports.ts index 0d6b223cc5..00368bcabc 100644 --- a/packages/core/src/js/integrations/exports.ts +++ b/packages/core/src/js/integrations/exports.ts @@ -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'; diff --git a/packages/core/test/feedback/FeedbackFormManager.test.tsx b/packages/core/test/feedback/FeedbackFormManager.test.tsx index 989a8fca07..010973e4fe 100644 --- a/packages/core/test/feedback/FeedbackFormManager.test.tsx +++ b/packages/core/test/feedback/FeedbackFormManager.test.tsx @@ -12,6 +12,7 @@ import { showFeedbackForm, } from '../../src/js/feedback/FeedbackFormManager'; import { FeedbackFormProvider } from '../../src/js/feedback/FeedbackFormProvider'; +import { getFullWindowOverlay } from '../../src/js/feedback/fullWindowOverlay'; import { feedbackIntegration } from '../../src/js/feedback/integration'; import { AUTO_INJECT_FEEDBACK_BUTTON_INTEGRATION_NAME, @@ -25,9 +26,18 @@ jest.mock('../../src/js/feedback/utils', () => ({ isNativeDriverSupportedForColorAnimations: jest.fn().mockReturnValue(true), })); +jest.mock('../../src/js/feedback/fullWindowOverlay', () => ({ + getFullWindowOverlay: jest.fn().mockReturnValue(undefined), + resetFullWindowOverlayCache: jest.fn(), +})); + const consoleWarnSpy = jest.spyOn(console, 'warn'); const mockedIsModalSupported = isModalSupported as jest.MockedFunction; +const mockedGetFullWindowOverlay = getFullWindowOverlay as jest.MockedFunction; + +// A passthrough stand-in for react-native-screens' FullWindowOverlay. +const MockFullWindowOverlay = ({ children }: { children?: React.ReactNode }): React.ReactNode => children; beforeEach(() => { debug.error = jest.fn(); @@ -193,6 +203,80 @@ describe('FeedbackFormManager', () => { }); }); +describe('FeedbackFormProvider presentOverNativeModals', () => { + beforeEach(() => { + const client = new TestClient(getDefaultTestClientOptions()); + setCurrentClient(client); + client.init(); + consoleWarnSpy.mockReset(); + resetFeedbackFormManager(); + mockedIsModalSupported.mockReturnValue(true); + mockedGetFullWindowOverlay.mockReturnValue(undefined); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('renders the form in a Modal by default (option disabled)', async () => { + mockedGetFullWindowOverlay.mockReturnValue(MockFullWindowOverlay); + getClient()?.addIntegration(feedbackIntegration()); + + const { queryByTestId } = render( + + App Components + , + ); + + await act(async () => { + showFeedbackForm(); + }); + + expect(queryByTestId('feedback-form-modal')).toBeTruthy(); + expect(queryByTestId('feedback-form-overlay')).toBeNull(); + // Gate is off, so react-native-screens is never consulted. + expect(mockedGetFullWindowOverlay).not.toHaveBeenCalled(); + }); + + it('renders the form in a FullWindowOverlay when enabled and react-native-screens is available', async () => { + mockedGetFullWindowOverlay.mockReturnValue(MockFullWindowOverlay); + getClient()?.addIntegration(feedbackIntegration({ _experiments: { presentOverNativeModals: true } })); + + const { queryByTestId } = render( + + App Components + , + ); + + await act(async () => { + showFeedbackForm(); + }); + + expect(queryByTestId('feedback-form-overlay')).toBeTruthy(); + expect(queryByTestId('feedback-form-modal')).toBeNull(); + }); + + it('falls back to Modal and warns when enabled but react-native-screens is unavailable', async () => { + const warnSpy = jest.spyOn(debug, 'warn').mockImplementation(() => {}); + mockedGetFullWindowOverlay.mockReturnValue(undefined); + getClient()?.addIntegration(feedbackIntegration({ _experiments: { presentOverNativeModals: true } })); + + const { queryByTestId } = render( + + App Components + , + ); + + await act(async () => { + showFeedbackForm(); + }); + + expect(queryByTestId('feedback-form-modal')).toBeTruthy(); + expect(queryByTestId('feedback-form-overlay')).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('presentOverNativeModals')); + }); +}); + describe('FeedbackButtonManager', () => { let listener: (preferences: Appearance.AppearancePreferences) => void; diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index 85aae107d2..af4a36530b 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -7,7 +7,7 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { createStackNavigator } from '@react-navigation/stack'; import * as Sentry from '@sentry/react-native'; import { isTurboModuleEnabled } from '@sentry/react-native/dist/js/utils/environment'; -import { LogBox, Platform } from 'react-native'; +import { Button, LogBox, Platform, Text, View } from 'react-native'; import * as ImagePicker from 'react-native-image-picker'; import RunningIndicator from './components/RunningIndicator'; @@ -130,6 +130,7 @@ Sentry.init({ enableScreenshot: true, enableTakeScreenshot: true, enableShakeToReport: true, + _experiments: { presentOverNativeModals: true }, styles: { submitButton: { backgroundColor: '#6a1b9a', @@ -263,6 +264,25 @@ function BottomTabsNavigator() { ); } +// Demonstrates the feedback form presented over a native-stack `formSheet`. +// Requires `feedbackIntegration({ _experiments: { presentOverNativeModals: true } })` +// so the form is rendered above the presented sheet on iOS. +function FormSheetScreen() { + return ( + + + This screen is a native-stack `formSheet`. Tap below (or shake) to open + the feedback form — it appears above this sheet, and typing, submit, + Cancel and swipe-down all work. + +