From 97d581275c2b062fbc947a53a8c9971b03698351 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 10 Nov 2026 15:39:54 +0100 Subject: [PATCH 1/4] fix(ios): Keep manual time to display working without legacy interop RNSentryOnDrawReporter is a legacy view manager, so React Native drops it when RCT_REMOVE_LEGACY_COMPONENT_INTEROP is set, and the TTID and TTFD components silently fall back to a plain View. Add a Fabric component that is only registered under that flag, and use a static view config only when React Native has no interop view config. Other builds stay on the existing path. Refs #7014 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + .../ios/RNSentryOnDrawReporterComponentView.h | 17 ++++ .../RNSentryOnDrawReporterComponentView.mm | 94 +++++++++++++++++++ packages/core/react-native.config.js | 3 + .../RNSentryOnDrawReporterNativeComponent.ts | 16 ++++ .../src/js/tracing/timetodisplaynative.tsx | 23 ++++- packages/core/src/js/utils/rnlibraries.ts | 7 ++ .../core/src/js/utils/rnlibrariesinterface.ts | 4 + packages/core/src/js/utils/worldwide.ts | 3 +- .../test/tracing/timetodisplaynative.test.tsx | 91 ++++++++++++++++++ .../tracing/timetodisplaynative.web.test.tsx | 3 +- 11 files changed, 260 insertions(+), 5 deletions(-) create mode 100644 packages/core/ios/RNSentryOnDrawReporterComponentView.h create mode 100644 packages/core/ios/RNSentryOnDrawReporterComponentView.mm create mode 100644 packages/core/src/js/RNSentryOnDrawReporterNativeComponent.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e08831bc6..28042e001e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ## Unreleased +### Fixes + +- Keep manual time to display working on iOS when React Native's legacy component interop is removed ([#7014](https://github.com/getsentry/sentry-react-native/issues/7014)) + ### Dependencies - Bump Android SDK from v8.62.0 to v8.63.0 ([#6990](https://github.com/getsentry/sentry-react-native/pull/6990)) diff --git a/packages/core/ios/RNSentryOnDrawReporterComponentView.h b/packages/core/ios/RNSentryOnDrawReporterComponentView.h new file mode 100644 index 0000000000..0f82611915 --- /dev/null +++ b/packages/core/ios/RNSentryOnDrawReporterComponentView.h @@ -0,0 +1,17 @@ +#import + +#if SENTRY_HAS_UIKIT + +# ifdef RCT_NEW_ARCH_ENABLED +# import + +/** + * Fabric version of the on-draw reporter, used only when React Native is built without the legacy + * component interop layer. Other builds keep the legacy view manager. + */ +@interface RNSentryOnDrawReporterComponentView : RCTViewComponentView +@end + +# endif + +#endif diff --git a/packages/core/ios/RNSentryOnDrawReporterComponentView.mm b/packages/core/ios/RNSentryOnDrawReporterComponentView.mm new file mode 100644 index 0000000000..b96be4aa3b --- /dev/null +++ b/packages/core/ios/RNSentryOnDrawReporterComponentView.mm @@ -0,0 +1,94 @@ +#import + +#if SENTRY_HAS_UIKIT + +# ifdef RCT_NEW_ARCH_ENABLED + +# import "RNSentryOnDrawReporter.h" +# import "RNSentryOnDrawReporterComponentView.h" + +# import +# import +# import +# import +# import +// RCTFabricComponentsPlugins needed for RNSentryOnDrawReporterCls +# import + +@interface RNSentryOnDrawReporterComponentView () +@end + +@implementation RNSentryOnDrawReporterComponentView { + // Holds the timing logic and is never mounted. Listening for frames doesn't need a window. + RNSentryOnDrawReporterView *_reporter; +} + +# ifdef RCT_REMOVE_LEGACY_COMPONENT_INTEROP +// Registered here rather than through `componentProvider`, which React Native prefers over interop. +// Apps that keep the interop layer stay on the legacy view manager. ++ (void)load +{ + [[RCTComponentViewFactory currentComponentViewFactory] registerComponentViewClass:self]; +} +# endif + +// Fabric requires typed default props, or mounting aborts in -updateProps:oldProps:. +- (instancetype)initWithFrame:(CGRect)frame +{ + if (self = [super initWithFrame:frame]) { + static const auto defaultProps + = std::make_shared(); + _props = defaultProps; + } + return self; +} + ++ (facebook::react::ComponentDescriptorProvider)componentDescriptorProvider +{ + return facebook::react::concreteComponentDescriptorProvider< + facebook::react::RNSentryOnDrawReporterComponentDescriptor>(); +} + +- (void)updateProps:(const facebook::react::Props::Shared &)props + oldProps:(const facebook::react::Props::Shared &)oldProps +{ + [super updateProps:props oldProps:oldProps]; + + const auto &newProps + = *std::static_pointer_cast(props); + + if (_reporter == nil) { + _reporter = [[RNSentryOnDrawReporterView alloc] init]; + } + _reporter.initialDisplay = newProps.initialDisplay; + _reporter.fullDisplay = newProps.fullDisplay; + // Codegen represents an unset string as empty, while the reporter treats nil as "no span". + _reporter.parentSpanId = newProps.parentSpanId.empty() + ? nil + : [NSString stringWithUTF8String:newProps.parentSpanId.c_str()]; + [_reporter didSetProps:@[]]; +} + +// Fabric reuses component views, and a stale span id would suppress the next screen's report. +- (void)prepareForRecycle +{ + [super prepareForRecycle]; + _reporter = nil; +} + +@end + +Class +RNSentryOnDrawReporterCls(void) +{ +# ifdef RCT_REMOVE_LEGACY_COMPONENT_INTEROP + return RNSentryOnDrawReporterComponentView.class; +# else + // Nil makes React Native fall through to the legacy interop layer. + return nil; +# endif +} + +# endif + +#endif diff --git a/packages/core/react-native.config.js b/packages/core/react-native.config.js index 050eb257b2..44321a0ded 100644 --- a/packages/core/react-native.config.js +++ b/packages/core/react-native.config.js @@ -13,6 +13,9 @@ module.exports = { android: { packageInstance: 'new RNSentryPackage()', packageImportPath: 'import io.sentry.react.RNSentryPackage;', + // Listed explicitly so autolinking skips RNSentryOnDrawReporter, which stays on the + // legacy interop layer on Android. + componentDescriptors: ['RNSentryReplayMaskComponentDescriptor', 'RNSentryReplayUnmaskComponentDescriptor'], }, }, }, diff --git a/packages/core/src/js/RNSentryOnDrawReporterNativeComponent.ts b/packages/core/src/js/RNSentryOnDrawReporterNativeComponent.ts new file mode 100644 index 0000000000..d3020864ea --- /dev/null +++ b/packages/core/src/js/RNSentryOnDrawReporterNativeComponent.ts @@ -0,0 +1,16 @@ +import type { HostComponent, ViewProps } from 'react-native'; + +// The default export exists in the file but the linter doesn't see it +import { codegenNativeComponent } from 'react-native'; + +export interface NativeProps extends ViewProps { + initialDisplay?: boolean; + fullDisplay?: boolean; + parentSpanId?: string; +} + +// Only iOS builds without the legacy interop layer use this component. Android has no flag to remove +// interop, so it keeps its legacy view manager (see `componentDescriptors` in react-native.config.js). +export default codegenNativeComponent('RNSentryOnDrawReporter', { + excludedPlatforms: ['android'], +}) as HostComponent; diff --git a/packages/core/src/js/tracing/timetodisplaynative.tsx b/packages/core/src/js/tracing/timetodisplaynative.tsx index 8d07ebec11..49921db21e 100644 --- a/packages/core/src/js/tracing/timetodisplaynative.tsx +++ b/packages/core/src/js/tracing/timetodisplaynative.tsx @@ -7,6 +7,7 @@ import type { RNSentryOnDrawReporterProps } from './timetodisplaynative.types'; import { isExpoGo } from '../utils/environment'; import { ReactNativeLibraries } from '../utils/rnlibraries'; +import { RN_GLOBAL_OBJ } from '../utils/worldwide'; const RNSentryOnDrawReporterClass = 'RNSentryOnDrawReporter'; @@ -23,17 +24,33 @@ class RNSentryOnDrawReporterNoop extends React.Component => ({ + uiViewClassName: RNSentryOnDrawReporterClass, + validAttributes: { initialDisplay: true, fullDisplay: true, parentSpanId: true }, +}); + +// React Native only has a native view config for this component through its legacy interop layer. +const hasNativeViewConfig = (): boolean => + !RN_GLOBAL_OBJ.RN$Bridgeless || !!RN_GLOBAL_OBJ.RN$LegacyInterop_UIManager_getConstants; + let RNSentryOnDrawReporter: HostComponent | typeof RNSentryOnDrawReporterNoop; +const resolveNativeReporter = (): HostComponent | undefined => { + const reactNative = ReactNativeLibraries.ReactNative; + const reporter = hasNativeViewConfig() + ? reactNative?.requireNativeComponent?.(RNSentryOnDrawReporterClass) + : reactNative?.getNativeComponent?.(RNSentryOnDrawReporterClass, getViewConfig); + // The vendored HostComponent type predates the one exported by current React Native versions. + return reporter as unknown as HostComponent | undefined; +}; + /** * Native component that reports the on draw timestamp. */ export const getRNSentryOnDrawReporter = (): typeof RNSentryOnDrawReporter => { if (!RNSentryOnDrawReporter) { RNSentryOnDrawReporter = - !isExpoGo() && nativeComponentExists && ReactNativeLibraries.ReactNative?.requireNativeComponent - ? ReactNativeLibraries.ReactNative.requireNativeComponent(RNSentryOnDrawReporterClass) - : RNSentryOnDrawReporterNoop; + (!isExpoGo() && nativeComponentExists && resolveNativeReporter()) || RNSentryOnDrawReporterNoop; } return RNSentryOnDrawReporter; }; diff --git a/packages/core/src/js/utils/rnlibraries.ts b/packages/core/src/js/utils/rnlibraries.ts index afef515493..3cad70c489 100644 --- a/packages/core/src/js/utils/rnlibraries.ts +++ b/packages/core/src/js/utils/rnlibraries.ts @@ -63,6 +63,13 @@ const InternalReactNativeLibrariesInterface: Required( + componentName: string, + viewConfigProvider: () => Record, + ): ReactNative.HostComponent => { + const { get } = require('react-native/Libraries/NativeComponent/NativeComponentRegistry'); + return get(componentName, viewConfigProvider); + }, }, }; diff --git a/packages/core/src/js/utils/rnlibrariesinterface.ts b/packages/core/src/js/utils/rnlibrariesinterface.ts index d3d94b2969..4524271f1e 100644 --- a/packages/core/src/js/utils/rnlibrariesinterface.ts +++ b/packages/core/src/js/utils/rnlibrariesinterface.ts @@ -28,5 +28,9 @@ export interface ReactNativeLibrariesInterface { AppRegistry?: ReactNative.AppRegistry; ReactNative?: { requireNativeComponent?: (viewName: string) => ReactNative.HostComponent; + getNativeComponent?: ( + componentName: string, + viewConfigProvider: () => Record, + ) => ReactNative.HostComponent; }; } diff --git a/packages/core/src/js/utils/worldwide.ts b/packages/core/src/js/utils/worldwide.ts index 8d98c354e3..4554b89545 100644 --- a/packages/core/src/js/utils/worldwide.ts +++ b/packages/core/src/js/utils/worldwide.ts @@ -23,7 +23,8 @@ export interface ReactNativeInternalGlobal extends InternalGlobal { }; Promise: unknown; __turboModuleProxy: unknown; - RN$Bridgeless: unknown; + RN$Bridgeless?: unknown; + RN$LegacyInterop_UIManager_getConstants?: unknown; /** Old Architecture JSI host-object backing `NativeModules`; not enumerable. */ nativeModuleProxy?: unknown; nativeFabricUIManager: unknown; diff --git a/packages/core/test/tracing/timetodisplaynative.test.tsx b/packages/core/test/tracing/timetodisplaynative.test.tsx index ec4dd5d75d..a221c7b53c 100644 --- a/packages/core/test/tracing/timetodisplaynative.test.tsx +++ b/packages/core/test/tracing/timetodisplaynative.test.tsx @@ -1,5 +1,8 @@ +import type * as TimeToDisplayNative from '../../src/js/tracing/timetodisplaynative'; + import { getRNSentryOnDrawReporter } from '../../src/js/tracing/timetodisplaynative'; import * as Environment from '../../src/js/utils/environment'; +import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide'; describe('timetodisplaynative', () => { beforeEach(() => { @@ -12,4 +15,92 @@ describe('timetodisplaynative', () => { expect(drawReported.name).toBe('RNSentryOnDrawReporterNoop'); }); + + describe('getRNSentryOnDrawReporter with a mocked native registry', () => { + const mockInteropComponent = jest.fn(); + const mockStaticComponent = jest.fn(); + const mockRequireNativeComponent = jest.fn(() => mockInteropComponent); + const mockNativeComponentRegistryGet = jest.fn(() => mockStaticComponent); + + const loadModule = (isRegistered: boolean): typeof TimeToDisplayNative => { + jest.doMock('../../src/js/utils/environment', () => ({ isExpoGo: () => false })); + jest.doMock('react-native/Libraries/NativeComponent/NativeComponentRegistry', () => ({ + get: mockNativeComponentRegistryGet, + })); + jest.doMock('react-native', () => ({ + Platform: { OS: 'ios', constants: {} }, + UIManager: { hasViewManagerConfig: () => isRegistered }, + View: jest.fn(), + requireNativeComponent: mockRequireNativeComponent, + })); + + return require('../../src/js/tracing/timetodisplaynative'); + }; + + beforeEach(() => { + jest.resetModules(); + mockRequireNativeComponent.mockClear(); + mockNativeComponentRegistryGet.mockClear(); + }); + + afterEach(() => { + delete RN_GLOBAL_OBJ.RN$Bridgeless; + delete RN_GLOBAL_OBJ.RN$LegacyInterop_UIManager_getConstants; + jest.dontMock('../../src/js/utils/environment'); + jest.dontMock('react-native/Libraries/NativeComponent/NativeComponentRegistry'); + jest.dontMock('react-native'); + }); + + it('uses the native view config on the Old Architecture', () => { + const { getRNSentryOnDrawReporter: getReporter } = loadModule(true); + + const reporter = getReporter(); + + expect(reporter).toBe(mockInteropComponent); + expect(mockRequireNativeComponent).toHaveBeenCalledWith('RNSentryOnDrawReporter'); + expect(mockNativeComponentRegistryGet).not.toHaveBeenCalled(); + }); + + it('uses the native view config while legacy interop is available', () => { + RN_GLOBAL_OBJ.RN$Bridgeless = true; + RN_GLOBAL_OBJ.RN$LegacyInterop_UIManager_getConstants = jest.fn(); + const { getRNSentryOnDrawReporter: getReporter } = loadModule(true); + + const reporter = getReporter(); + + expect(reporter).toBe(mockInteropComponent); + expect(mockRequireNativeComponent).toHaveBeenCalledWith('RNSentryOnDrawReporter'); + expect(mockNativeComponentRegistryGet).not.toHaveBeenCalled(); + }); + + it('uses a static view config when legacy interop is removed', () => { + RN_GLOBAL_OBJ.RN$Bridgeless = true; + const { getRNSentryOnDrawReporter: getReporter } = loadModule(true); + + const reporter = getReporter(); + + expect(reporter).toBe(mockStaticComponent); + expect(mockRequireNativeComponent).not.toHaveBeenCalled(); + expect(mockNativeComponentRegistryGet).toHaveBeenCalledTimes(1); + const [componentName, viewConfigProvider] = mockNativeComponentRegistryGet.mock.calls[0] as unknown as [ + string, + () => unknown, + ]; + expect(componentName).toBe('RNSentryOnDrawReporter'); + expect(viewConfigProvider()).toEqual({ + uiViewClassName: 'RNSentryOnDrawReporter', + validAttributes: { initialDisplay: true, fullDisplay: true, parentSpanId: true }, + }); + }); + + it('returns Noop when the component is not registered', () => { + const { getRNSentryOnDrawReporter: getReporter } = loadModule(false); + + const reporter = getReporter(); + + expect(reporter.name).toBe('RNSentryOnDrawReporterNoop'); + expect(mockRequireNativeComponent).not.toHaveBeenCalled(); + expect(mockNativeComponentRegistryGet).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/core/test/tracing/timetodisplaynative.web.test.tsx b/packages/core/test/tracing/timetodisplaynative.web.test.tsx index ccd5100d61..f90f781829 100644 --- a/packages/core/test/tracing/timetodisplaynative.web.test.tsx +++ b/packages/core/test/tracing/timetodisplaynative.web.test.tsx @@ -18,9 +18,10 @@ jest.mock('../../src/js/utils/rnlibraries', () => { }); describe('timetodisplaynative', () => { - test('requireNativeComponent to be undefined', () => { + test('native component getters to be undefined', () => { expect(ReactNativeLibraries).toBeDefined(); expect(ReactNativeLibraries.ReactNative?.requireNativeComponent).not.toBeDefined(); + expect(ReactNativeLibraries.ReactNative?.getNativeComponent).not.toBeDefined(); }); test('getRNSentryOnDrawReporter returns Noop', () => { From af5d6cfb8a506e5f2c9c9308cd457c08e334b44c Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 10 Nov 2026 15:40:00 +0100 Subject: [PATCH 2/4] docs(ios): Link the PR in the changelog entry Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28042e001e..bf78bfa9f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Fixes -- Keep manual time to display working on iOS when React Native's legacy component interop is removed ([#7014](https://github.com/getsentry/sentry-react-native/issues/7014)) +- Keep manual time to display working on iOS when React Native's legacy component interop is removed ([#7015](https://github.com/getsentry/sentry-react-native/pull/7015)) ### Dependencies From 6910f4360aefd2b9ee489bbd666a6227a08044b7 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 10 Nov 2026 15:59:56 +0100 Subject: [PATCH 3/4] fix(ios): Include the on-draw reporter codegen spec in the package Codegen reads the spec from the published package, and .npmignore lists each spec by name. Co-Authored-By: Claude Opus 5.5 --- packages/core/.npmignore | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/.npmignore b/packages/core/.npmignore index 57238e445d..7c3aee39bf 100644 --- a/packages/core/.npmignore +++ b/packages/core/.npmignore @@ -19,6 +19,7 @@ # New Architecture Codegen !src/js/NativeRNSentry.ts +!src/js/RNSentryOnDrawReporterNativeComponent.ts !src/js/RNSentryReplayMaskNativeComponent.ts !src/js/RNSentryReplayUnmaskNativeComponent.ts From 6132cff49b49c38e049509d561621b0e6327c3c7 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 10 Nov 2026 17:03:29 +0100 Subject: [PATCH 4/4] docs(android): Note that new Fabric components must be listed for autolinking Co-Authored-By: Claude Opus 5.5 --- packages/core/react-native.config.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/react-native.config.js b/packages/core/react-native.config.js index 44321a0ded..a4d3581a89 100644 --- a/packages/core/react-native.config.js +++ b/packages/core/react-native.config.js @@ -14,7 +14,8 @@ module.exports = { packageInstance: 'new RNSentryPackage()', packageImportPath: 'import io.sentry.react.RNSentryPackage;', // Listed explicitly so autolinking skips RNSentryOnDrawReporter, which stays on the - // legacy interop layer on Android. + // legacy interop layer on Android. Add any new Fabric component here, or Android + // autolinking skips it without an error. componentDescriptors: ['RNSentryReplayMaskComponentDescriptor', 'RNSentryReplayUnmaskComponentDescriptor'], }, },