From 01457f86243a1dce272adf6c60160f1b939e3ed1 Mon Sep 17 00:00:00 2001 From: DjDeveloperr Date: Tue, 26 May 2026 01:03:52 -0400 Subject: [PATCH 1/2] feat(react-native): add UIKit view debug names --- packages/react-native/README.md | 8 ++++-- .../Fabric/NativeScriptUIViewComponentView.mm | 28 +++++++++++++++++++ .../react-native/ios/NativeScriptUIView.h | 1 + .../react-native/ios/NativeScriptUIView.mm | 23 +++++++++++++++ .../ios/NativeScriptUIViewManager.mm | 1 + .../src/NativeScriptUIViewNativeComponent.ts | 1 + packages/react-native/src/index.d.ts | 15 ++++++++++ packages/react-native/src/index.ts | 11 +++++++- test/react-native/ffi-compat/App.tsx | 6 +++- 9 files changed, 90 insertions(+), 4 deletions(-) diff --git a/packages/react-native/README.md b/packages/react-native/README.md index bb88da7c..c8ecd28c 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -47,7 +47,7 @@ type BadgeProps = { }; export const NativeBadge = defineUIKitView({ - displayName: "NativeBadge", + name: "NativeBadge", create() { const view = UIView.alloc().initWithFrame(CGRectZero); const label = UILabel.alloc().initWithFrame(CGRectZero); @@ -85,7 +85,10 @@ await badgeRef.current?.runOnUI((view) => { React Native view props such as `style`, `testID`, accessibility props, responder props, and `pointerEvents` go to the host component. Your own props go to the UIKit definition; use `nativeProps(props)` when a plugin prop should also affect -the RN host. +the RN host. The `name` option is forwarded to the shared native host view as a +debug name, so native view descriptions can show `NativeScriptUIView` with your +definition name. It does not dynamically change the registered RN host component +tag. The published package includes generated NativeScript metadata, the libffi xcframework, and generated iOS SDK TypeScript declarations. Build it from the @@ -173,6 +176,7 @@ Expo development build, EAS Build, or `npx expo run:ios`. NativeScript.init(); const NativeBadge = defineUIKitView<{title: string}, UIView>({ + name: "NativeBadge", create() { const view = UIView.alloc().initWithFrame(CGRectZero); const label = UILabel.alloc().initWithFrame(CGRectZero); diff --git a/packages/react-native/ios/Fabric/NativeScriptUIViewComponentView.mm b/packages/react-native/ios/Fabric/NativeScriptUIViewComponentView.mm index fea3fb76..29a7faa0 100644 --- a/packages/react-native/ios/Fabric/NativeScriptUIViewComponentView.mm +++ b/packages/react-native/ios/Fabric/NativeScriptUIViewComponentView.mm @@ -10,6 +10,7 @@ @implementation NativeScriptUIViewComponentView { NativeScriptUIView* _containerView; + NSString* _debugName; } - (instancetype)initWithFrame:(CGRect)frame { @@ -27,18 +28,42 @@ - (instancetype)initWithFrame:(CGRect)frame { } - (void)dealloc { + [_debugName release]; [_containerView release]; [super dealloc]; } +- (NSString*)description { + if (_debugName.length == 0) { + return [super description]; + } + + NSString* description = [super description]; + if ([description hasSuffix:@">"]) { + return [[description substringToIndex:description.length - 1] + stringByAppendingFormat:@"; debugName = %@>", _debugName]; + } + return [description stringByAppendingFormat:@" debugName = %@", _debugName]; +} + - (void)updateProps:(Props::Shared const&)props oldProps:(Props::Shared const&)oldProps { const auto oldViewProps = std::static_pointer_cast(_props); const auto newViewProps = std::static_pointer_cast(props); const std::string oldNativeViewHandle = oldViewProps->nativeViewHandle; const std::string newNativeViewHandle = newViewProps->nativeViewHandle; + const std::string oldDebugName = oldViewProps->debugName; + const std::string newDebugName = newViewProps->debugName; [super updateProps:props oldProps:oldProps]; + if (oldDebugName != newDebugName) { + NSString* debugName = + newDebugName.empty() ? nil : [NSString stringWithUTF8String:newDebugName.c_str()]; + [_debugName release]; + _debugName = [debugName copy]; + _containerView.debugName = debugName; + } + if (oldNativeViewHandle != newNativeViewHandle) { NSString* nativeViewHandle = newNativeViewHandle.empty() ? nil @@ -49,6 +74,9 @@ - (void)updateProps:(Props::Shared const&)props oldProps:(Props::Shared const&)o - (void)prepareForRecycle { [super prepareForRecycle]; + [_debugName release]; + _debugName = nil; + _containerView.debugName = nil; _containerView.nativeViewHandle = nil; } diff --git a/packages/react-native/ios/NativeScriptUIView.h b/packages/react-native/ios/NativeScriptUIView.h index 82d09405..89767f79 100644 --- a/packages/react-native/ios/NativeScriptUIView.h +++ b/packages/react-native/ios/NativeScriptUIView.h @@ -3,5 +3,6 @@ @interface NativeScriptUIView : UIView @property(nonatomic, copy) NSString* nativeViewHandle; +@property(nonatomic, copy) NSString* debugName; @end diff --git a/packages/react-native/ios/NativeScriptUIView.mm b/packages/react-native/ios/NativeScriptUIView.mm index ae0430c8..cf826b6f 100644 --- a/packages/react-native/ios/NativeScriptUIView.mm +++ b/packages/react-native/ios/NativeScriptUIView.mm @@ -32,6 +32,7 @@ - (void)dealloc { [_nativeView removeFromSuperview]; [_nativeView release]; [_nativeViewHandle release]; + [_debugName release]; [super dealloc]; } @@ -46,6 +47,28 @@ - (void)setNativeViewHandle:(NSString*)nativeViewHandle { [self setNativeView:NativeScriptUIViewFromHandle(_nativeViewHandle)]; } +- (void)setDebugName:(NSString*)debugName { + if ((_debugName == debugName) || [_debugName isEqualToString:debugName]) { + return; + } + + [_debugName release]; + _debugName = [debugName copy]; +} + +- (NSString*)description { + if (_debugName.length == 0) { + return [super description]; + } + + NSString* description = [super description]; + if ([description hasSuffix:@">"]) { + return [[description substringToIndex:description.length - 1] + stringByAppendingFormat:@"; debugName = %@>", _debugName]; + } + return [description stringByAppendingFormat:@" debugName = %@", _debugName]; +} + - (void)setNativeView:(UIView*)nativeView { if (_nativeView == nativeView) { return; diff --git a/packages/react-native/ios/NativeScriptUIViewManager.mm b/packages/react-native/ios/NativeScriptUIViewManager.mm index ed7af13d..dab55ce2 100644 --- a/packages/react-native/ios/NativeScriptUIViewManager.mm +++ b/packages/react-native/ios/NativeScriptUIViewManager.mm @@ -14,5 +14,6 @@ - (UIView*)view { } RCT_EXPORT_VIEW_PROPERTY(nativeViewHandle, NSString) +RCT_EXPORT_VIEW_PROPERTY(debugName, NSString) @end diff --git a/packages/react-native/src/NativeScriptUIViewNativeComponent.ts b/packages/react-native/src/NativeScriptUIViewNativeComponent.ts index 281e0607..c90b7896 100644 --- a/packages/react-native/src/NativeScriptUIViewNativeComponent.ts +++ b/packages/react-native/src/NativeScriptUIViewNativeComponent.ts @@ -3,6 +3,7 @@ import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNati export interface NativeProps extends ViewProps { nativeViewHandle?: string; + debugName?: string; } export default codegenNativeComponent( diff --git a/packages/react-native/src/index.d.ts b/packages/react-native/src/index.d.ts index 7d24ded4..0c7556c6 100644 --- a/packages/react-native/src/index.d.ts +++ b/packages/react-native/src/index.d.ts @@ -38,6 +38,21 @@ export type InstallOptions = { }; export type UIKitViewDefinition = { + /** + * Human-readable name for this UIKit view definition. This names the JS + * wrapper when displayName is omitted and is forwarded to the shared native + * host view as a debug name. It does not change the RN host component tag. + */ + name?: string; + /** + * Explicit native debug name for the shared host view. Use this when the + * native inspector name should differ from the JS wrapper displayName. + */ + debugName?: string; + /** + * React component display name. When name/debugName are omitted, this is also + * used as the native debug name. + */ displayName?: string; create: (props: Readonly) => NativeView; update?: ( diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index 2e1aa774..d681c6ec 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -36,6 +36,8 @@ export type InstallOptions = { }; export type UIKitViewDefinition = { + name?: string; + debugName?: string; displayName?: string; create: (props: Readonly) => NativeView; update?: ( @@ -680,6 +682,12 @@ export function runOnUI(callback?: () => void): Promise { export function defineUIKitView( definition: UIKitViewDefinition, ): UIKitViewComponent { + const debugName = + definition.debugName + || definition.name + || definition.displayName + || 'NativeScriptUIKitView'; + const Component = forwardRef, Props & ViewProps>( function NativeScriptUIKitView(props, ref) { const {nativeProps, pluginProps} = splitUIKitViewProps(props, definition); @@ -804,12 +812,13 @@ export function defineUIKitView( return React.createElement(NativeScriptUIViewNativeComponent, { ...nativeProps, collapsable: false, + debugName, nativeViewHandle, }); }, ); - Component.displayName = definition.displayName ?? 'NativeScriptUIKitView'; + Component.displayName = definition.displayName || definition.name || debugName; return Component; } diff --git a/test/react-native/ffi-compat/App.tsx b/test/react-native/ffi-compat/App.tsx index 9cf0b687..9c02c61e 100644 --- a/test/react-native/ffi-compat/App.tsx +++ b/test/react-native/ffi-compat/App.tsx @@ -625,7 +625,7 @@ const NativeScriptUIKitTestView = defineUIKitView<{ title: string; tint: 'blue' | 'green'; }>({ - displayName: 'NativeScriptUIKitTestView', + name: 'NativeScriptUIKitTestView', create(props) { const view = g('UIView').alloc().initWithFrame( new (g('CGRect'))({ @@ -789,6 +789,10 @@ function buildReactNativeIntegrationTests(): TestCase[] { await NativeScript.runOnUI(() => { const view = (globalThis as any).__nativeScriptUIKitPlugin?.view; assert(view?.superview, 'JS-defined UIKit view has no host superview'); + assert( + String(view.superview.description).includes('NativeScriptUIKitTestView'), + 'JS-defined UIKit host did not expose its debug name', + ); assert(view?.window, 'JS-defined UIKit view has no window'); const label = view.viewWithTag(uikitPluginLabelTag); assertEqual(label.text, 'Initial UIKit title', 'initial UIKit label text'); From df83b35810e4e67b2dc3ef21c594d7f6e63b6f1b Mon Sep 17 00:00:00 2001 From: DjDeveloperr Date: Tue, 26 May 2026 01:03:56 -0400 Subject: [PATCH 2/2] fix(ffi): rethrow JSI block callback errors --- .../ffi/shared/jsi/NativeApiJsiBridge.h | 46 +++++++++++++++++++ .../ffi/shared/jsi/NativeApiJsiCallbacks.h | 17 ++++++- .../app/tests/Marshalling/ObjCTypesTests.js | 14 ++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/NativeScript/ffi/shared/jsi/NativeApiJsiBridge.h b/NativeScript/ffi/shared/jsi/NativeApiJsiBridge.h index 6485c374..dee0ef17 100644 --- a/NativeScript/ffi/shared/jsi/NativeApiJsiBridge.h +++ b/NativeScript/ffi/shared/jsi/NativeApiJsiBridge.h @@ -2,6 +2,7 @@ thread_local bool gDispatchNativeCallsToUI = false; thread_local bool gExecutingDispatchedUINativeCall = false; thread_local int gSynchronousNativeInvocationDepth = 0; thread_local int gNativeCallerThreadJsiCallbackDepth = 0; +thread_local std::vector gNativeCallbackExceptionCaptureStack; std::atomic gActiveSynchronousNativeInvocationDepth{0}; class ScopedNativeApiUINativeCallDispatch final { @@ -54,14 +55,56 @@ class ScopedNativeCallerThreadJsiCallback final { const ScopedNativeCallerThreadJsiCallback&) = delete; }; +class ScopedNativeCallbackExceptionCapture final { + public: + explicit ScopedNativeCallbackExceptionCapture(std::string* message) + : message_(message) { + gNativeCallbackExceptionCaptureStack.push_back(message_); + } + + ~ScopedNativeCallbackExceptionCapture() { + if (!gNativeCallbackExceptionCaptureStack.empty() && + gNativeCallbackExceptionCaptureStack.back() == message_) { + gNativeCallbackExceptionCaptureStack.pop_back(); + } + } + + ScopedNativeCallbackExceptionCapture( + const ScopedNativeCallbackExceptionCapture&) = delete; + ScopedNativeCallbackExceptionCapture& operator=( + const ScopedNativeCallbackExceptionCapture&) = delete; + + private: + std::string* message_ = nullptr; +}; + +bool recordNativeCallbackException(const std::string& message) { + if (gNativeCallbackExceptionCaptureStack.empty()) { + return false; + } + + std::string* captured = gNativeCallbackExceptionCaptureStack.back(); + if (captured == nullptr) { + return false; + } + + if (captured->empty()) { + *captured = message; + } + return true; +} + template void performNativeInvocation(Runtime& runtime, const std::function)>& invoker, Invocation&& invocation) { NSString* exceptionDescription = nil; + std::string callbackException; auto run = [&]() { ScopedNativeApiSynchronousInvocation synchronousInvocation; + ScopedNativeCallbackExceptionCapture callbackExceptionCapture( + &callbackException); @try { invocation(); } @catch (NSException* exception) { @@ -92,6 +135,9 @@ void performNativeInvocation(Runtime& runtime, [exceptionDescription release]; throw facebook::jsi::JSError(runtime, message); } + if (!callbackException.empty()) { + throw facebook::jsi::JSError(runtime, callbackException); + } } enum class NativeApiSymbolKind { diff --git a/NativeScript/ffi/shared/jsi/NativeApiJsiCallbacks.h b/NativeScript/ffi/shared/jsi/NativeApiJsiCallbacks.h index 2496bb5a..a9e9b6a2 100644 --- a/NativeScript/ffi/shared/jsi/NativeApiJsiCallbacks.h +++ b/NativeScript/ffi/shared/jsi/NativeApiJsiCallbacks.h @@ -478,7 +478,9 @@ class NativeApiJsiCallback final } if (!error.empty()) { - throwNativeApiJsiCallbackException(error); + if (!recordNativeCallbackException(error)) { + throwNativeApiJsiCallbackException(error); + } } } @@ -665,7 +667,18 @@ void nativeApiJsiCallbackTrampoline(ffi_cif*, void* ret, void* args[], if (callback == nullptr) { return; } - callback->invoke(ret, args); + @try { + callback->invoke(ret, args); + } @catch (NSException* exception) { + const char* description = + exception.description != nil ? exception.description.UTF8String : nullptr; + std::string message = description != nullptr + ? description + : "Objective-C exception in native JSI callback."; + if (!recordNativeCallbackException(message)) { + @throw; + } + } } size_t nativeSizeForType(const NativeApiJsiType& type) { diff --git a/test/runtime/runner/app/tests/Marshalling/ObjCTypesTests.js b/test/runtime/runner/app/tests/Marshalling/ObjCTypesTests.js index 1c86f878..f1d7abd1 100644 --- a/test/runtime/runner/app/tests/Marshalling/ObjCTypesTests.js +++ b/test/runtime/runner/app/tests/Marshalling/ObjCTypesTests.js @@ -96,6 +96,20 @@ describe(module.id, function () { expect(actual).toBe("simple block called"); }); + it("Block callback JS error propagates to the native caller", function () { + var error; + try { + TNSObjCTypes.alloc().init().methodWithSimpleBlock(function () { + throw new Error("block callback failed"); + }); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(String(error && error.message ? error.message : error)).toContain("block callback failed"); + }); + it("Block releases after call", function (done) { const functionRef = new WeakRef(function () { TNSLog('simple block called');