From 92a24205b3ff9b155b03cdf4e48b2d33e516c635 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 13 Oct 2026 11:31:50 +0200 Subject: [PATCH 1/7] fix(feedback): Show feedback form above native-stack sheets on iOS (WIP) On iOS a React Native `Modal` presents from the root view controller's presentation chain. When a native-stack screen is already presented (e.g. `presentation: 'formSheet'`), UIKit refuses the second presentation and the feedback form silently fails to appear (#6875). Render the form inside react-native-screens' `FullWindowOverlay` on iOS when it resolves (optional require, Metro-optional dependency), falling back to `Modal` otherwise. Android stays on `Modal`. This covers both `showFeedbackForm()` and shake-to-report, since both go through `FeedbackFormProvider`. WIP: currently auto-enabled whenever react-native-screens is present. Still TODO: opt-in experimental option (`_experiments.presentOverNativeModals`), overlay-path unit tests, device-verify close/swipe-dismiss, CHANGELOG. Signed-off-by: Antonis Lilis Co-Authored-By: Claude Opus 4.8 --- .../src/js/feedback/FeedbackFormProvider.tsx | 64 +++++++++++++------ .../core/src/js/feedback/fullWindowOverlay.ts | 46 +++++++++++++ 2 files changed, 92 insertions(+), 18 deletions(-) create mode 100644 packages/core/src/js/feedback/fullWindowOverlay.ts diff --git a/packages/core/src/js/feedback/FeedbackFormProvider.tsx b/packages/core/src/js/feedback/FeedbackFormProvider.tsx index ac4ad45cd2..8d0f15c711 100644 --- a/packages/core/src/js/feedback/FeedbackFormProvider.tsx +++ b/packages/core/src/js/feedback/FeedbackFormProvider.tsx @@ -37,6 +37,7 @@ import { getScreenshotButtonOptions, isShakeToReportEnabled, } from './integration'; +import { getFullWindowOverlay } from './fullWindowOverlay'; import { lazyLoadShakeToReportIntegration } from './lazy'; import { ScreenshotButton } from './ScreenshotButton'; import { startShakeListener, stopShakeListener } from './ShakeToReportBug'; @@ -196,6 +197,50 @@ 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 (see #6875). `FullWindowOverlay` from react-native-screens renders above + // everything in the key window — including presented sheets — so the form shows. + // When react-native-screens isn't installed we fall back to `Modal`. + const FullWindowOverlay = Platform.OS === 'ios' ? 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 +257,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; +} From 903b83994d9199e0f1e8819b0a3c844497fa3e47 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 13 Oct 2026 12:16:32 +0200 Subject: [PATCH 2/7] feat(feedback): Gate iOS over-sheet presentation behind _experiments.presentOverNativeModals Make the FullWindowOverlay presentation opt-in instead of auto-enabled: `feedbackIntegration({ _experiments: { presentOverNativeModals: true } })` (iOS only, requires react-native-screens; default off). When disabled or when react-native-screens isn't installed, the form keeps using Modal; an enabled-but-missing setup logs a debug warning. - integration: add `_experiments.presentOverNativeModals` + exported `FeedbackExperiments` type and `isPresentOverNativeModalsEnabled()`. - provider: gate the overlay branch on the option; warn on fallback. - tests: default Modal / overlay-when-enabled / fallback-and-warn. - api-report + CHANGELOG updated. Verified on iOS sim (New Arch): with the option on, Sentry.showFeedbackForm() renders above a native-stack formSheet with no "already presenting" error. Still TODO: Old Architecture verification, device-verify close/swipe-dismiss and interactive keyboard, decide whether to default-on in a later release. Signed-off-by: Antonis Lilis Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + packages/core/etc/sentry-react-native.api.md | 6 ++ .../src/js/feedback/FeedbackFormProvider.tsx | 14 +++- packages/core/src/js/feedback/integration.ts | 31 +++++++ packages/core/src/js/integrations/exports.ts | 1 + .../feedback/FeedbackFormManager.test.tsx | 84 +++++++++++++++++++ 6 files changed, 135 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6830378a55..469ec50176 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ ### Fixes +- Show the feedback form above native-stack sheets/modals on iOS, behind the experimental `feedbackIntegration({ _experiments: { presentOverNativeModals: true } })` option (requires `react-native-screens`) ([#6875](https://github.com/getsentry/sentry-react-native/issues/6875)) - 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/src/js/feedback/FeedbackFormProvider.tsx b/packages/core/src/js/feedback/FeedbackFormProvider.tsx index 8d0f15c711..2c27e26470 100644 --- a/packages/core/src/js/feedback/FeedbackFormProvider.tsx +++ b/packages/core/src/js/feedback/FeedbackFormProvider.tsx @@ -35,6 +35,7 @@ import { getFeedbackButtonOptions, getFeedbackOptions, getScreenshotButtonOptions, + isPresentOverNativeModalsEnabled, isShakeToReportEnabled, } from './integration'; import { getFullWindowOverlay } from './fullWindowOverlay'; @@ -137,6 +138,13 @@ export class FeedbackFormProvider extends React.Component; buttonOptions: Partial; @@ -13,6 +31,7 @@ type FeedbackIntegration = Integration & { themeLight: Partial; themeDark: Partial; enableShakeToReport: boolean; + experiments: FeedbackExperiments; }; export const feedbackIntegration = ( @@ -31,6 +50,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 +64,7 @@ export const feedbackIntegration = ( themeLight: lightTheme, themeDark: darkTheme, enableShakeToReport: shakeToReport, + _experiments: experiments, ...widgetOptions } = initOptions; @@ -52,6 +77,7 @@ export const feedbackIntegration = ( themeLight: lightTheme || {}, themeDark: darkTheme || {}, enableShakeToReport: shakeToReport || false, + experiments: experiments || {}, }; }; @@ -117,3 +143,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..46417d0ba8 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 (#6875)', () => { + 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; From 170b868c7f69452706d65ecb65268392fb1b4238 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 13 Oct 2026 13:37:53 +0200 Subject: [PATCH 3/7] chore(feedback): add RN sample demo and tidy comments - Add an "Open Form Sheet" entry to the Playground tab that presents a native-stack formSheet with a button to open the feedback form, demonstrating `_experiments.presentOverNativeModals`. - Remove issue-number references from code comments and test names. Signed-off-by: Antonis Lilis Co-Authored-By: Claude Opus 4.8 --- .../src/js/feedback/FeedbackFormProvider.tsx | 4 +-- .../feedback/FeedbackFormManager.test.tsx | 2 +- samples/react-native/src/App.tsx | 27 ++++++++++++++++++- .../react-native/src/tabs/PlaygroundTab.tsx | 7 +++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/core/src/js/feedback/FeedbackFormProvider.tsx b/packages/core/src/js/feedback/FeedbackFormProvider.tsx index 2c27e26470..990d5a6b2f 100644 --- a/packages/core/src/js/feedback/FeedbackFormProvider.tsx +++ b/packages/core/src/js/feedback/FeedbackFormProvider.tsx @@ -227,8 +227,8 @@ export class FeedbackFormProvider extends React.Component { }); }); -describe('FeedbackFormProvider presentOverNativeModals (#6875)', () => { +describe('FeedbackFormProvider presentOverNativeModals', () => { beforeEach(() => { const client = new TestClient(getDefaultTestClientOptions()); setCurrentClient(client); 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. + +