Skip to content
Closed
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
});
```

### Fixes

- 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

- Bump Android SDK from v8.62.0 to v8.63.0 ([#6990](https://github.com/getsentry/sentry-react-native/pull/6990))
Expand Down
1 change: 1 addition & 0 deletions packages/core/.npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

# New Architecture Codegen
!src/js/NativeRNSentry.ts
!src/js/RNSentryOnDrawReporterNativeComponent.ts
!src/js/RNSentryReplayMaskNativeComponent.ts
!src/js/RNSentryReplayUnmaskNativeComponent.ts

Expand Down
17 changes: 17 additions & 0 deletions packages/core/ios/RNSentryOnDrawReporterComponentView.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#import <Sentry/SentryDefines.h>

#if SENTRY_HAS_UIKIT

# ifdef RCT_NEW_ARCH_ENABLED
# import <React/RCTViewComponentView.h>

/**
* 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
94 changes: 94 additions & 0 deletions packages/core/ios/RNSentryOnDrawReporterComponentView.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#import <Sentry/SentryDefines.h>

#if SENTRY_HAS_UIKIT

# ifdef RCT_NEW_ARCH_ENABLED

# import "RNSentryOnDrawReporter.h"
# import "RNSentryOnDrawReporterComponentView.h"

# import <React/RCTComponentViewFactory.h>
# import <React/UIView+React.h>
# import <react/renderer/components/RNSentrySpec/ComponentDescriptors.h>
# import <react/renderer/components/RNSentrySpec/Props.h>
# import <react/renderer/components/RNSentrySpec/RCTComponentViewHelpers.h>
// RCTFabricComponentsPlugins needed for RNSentryOnDrawReporterCls
# import <React/RCTFabricComponentsPlugins.h>

@interface RNSentryOnDrawReporterComponentView () <RCTRNSentryOnDrawReporterViewProtocol>
@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<const facebook::react::RNSentryOnDrawReporterProps>();
_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<const facebook::react::RNSentryOnDrawReporterProps>(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<RCTComponentViewProtocol>
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
4 changes: 4 additions & 0 deletions packages/core/react-native.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ 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. Add any new Fabric component here, or Android
// autolinking skips it without an error.
componentDescriptors: ['RNSentryReplayMaskComponentDescriptor', 'RNSentryReplayUnmaskComponentDescriptor'],
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Resolved
},
},
},
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/js/RNSentryOnDrawReporterNativeComponent.ts
Original file line number Diff line number Diff line change
@@ -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<NativeProps>('RNSentryOnDrawReporter', {
excludedPlatforms: ['android'],
}) as HostComponent<NativeProps>;
23 changes: 20 additions & 3 deletions packages/core/src/js/tracing/timetodisplaynative.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -23,17 +24,33 @@ class RNSentryOnDrawReporterNoop extends React.Component<RNSentryOnDrawReporterP
}
}

const getViewConfig = (): Record<string, unknown> => ({
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<RNSentryOnDrawReporterProps> | typeof RNSentryOnDrawReporterNoop;

const resolveNativeReporter = (): HostComponent<RNSentryOnDrawReporterProps> | undefined => {
const reactNative = ReactNativeLibraries.ReactNative;
const reporter = hasNativeViewConfig()
? reactNative?.requireNativeComponent?.<RNSentryOnDrawReporterProps>(RNSentryOnDrawReporterClass)
: reactNative?.getNativeComponent?.<RNSentryOnDrawReporterProps>(RNSentryOnDrawReporterClass, getViewConfig);
// The vendored HostComponent type predates the one exported by current React Native versions.
return reporter as unknown as HostComponent<RNSentryOnDrawReporterProps> | 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;
}
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Resolved
return RNSentryOnDrawReporter;
};
7 changes: 7 additions & 0 deletions packages/core/src/js/utils/rnlibraries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ const InternalReactNativeLibrariesInterface: Required<ReactNativeLibrariesInterf
const { requireNativeComponent } = require('react-native');
return requireNativeComponent(viewName);
},
getNativeComponent: <T>(
componentName: string,
viewConfigProvider: () => Record<string, unknown>,
): ReactNative.HostComponent<T> => {
const { get } = require('react-native/Libraries/NativeComponent/NativeComponentRegistry');
return get(componentName, viewConfigProvider);
},
},
};

Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/js/utils/rnlibrariesinterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,9 @@ export interface ReactNativeLibrariesInterface {
AppRegistry?: ReactNative.AppRegistry;
ReactNative?: {
requireNativeComponent?: <T>(viewName: string) => ReactNative.HostComponent<T>;
getNativeComponent?: <T>(
componentName: string,
viewConfigProvider: () => Record<string, unknown>,
) => ReactNative.HostComponent<T>;
};
}
3 changes: 2 additions & 1 deletion packages/core/src/js/utils/worldwide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
91 changes: 91 additions & 0 deletions packages/core/test/tracing/timetodisplaynative.test.tsx
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand All @@ -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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading