Skip to content
Merged
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
46 changes: 46 additions & 0 deletions NativeScript/ffi/shared/jsi/NativeApiJsiBridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::string*> gNativeCallbackExceptionCaptureStack;
std::atomic<int> gActiveSynchronousNativeInvocationDepth{0};

class ScopedNativeApiUINativeCallDispatch final {
Expand Down Expand Up @@ -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 <typename Invocation>
void performNativeInvocation(Runtime& runtime,
const std::function<void(std::function<void()>)>&
invoker,
Invocation&& invocation) {
NSString* exceptionDescription = nil;
std::string callbackException;
auto run = [&]() {
ScopedNativeApiSynchronousInvocation synchronousInvocation;
ScopedNativeCallbackExceptionCapture callbackExceptionCapture(
&callbackException);
@try {
invocation();
} @catch (NSException* exception) {
Expand Down Expand Up @@ -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 {
Expand Down
17 changes: 15 additions & 2 deletions NativeScript/ffi/shared/jsi/NativeApiJsiCallbacks.h
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,9 @@ class NativeApiJsiCallback final
}

if (!error.empty()) {
throwNativeApiJsiCallbackException(error);
if (!recordNativeCallbackException(error)) {
throwNativeApiJsiCallbackException(error);
}
}
}

Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 6 additions & 2 deletions packages/react-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ type BadgeProps = {
};

export const NativeBadge = defineUIKitView<BadgeProps, UIView>({
displayName: "NativeBadge",
name: "NativeBadge",
create() {
const view = UIView.alloc().initWithFrame(CGRectZero);
const label = UILabel.alloc().initWithFrame(CGRectZero);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

@implementation NativeScriptUIViewComponentView {
NativeScriptUIView* _containerView;
NSString* _debugName;
}

- (instancetype)initWithFrame:(CGRect)frame {
Expand All @@ -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<const NativeScriptUIViewProps>(_props);
const auto newViewProps = std::static_pointer_cast<const NativeScriptUIViewProps>(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
Expand All @@ -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;
}

Expand Down
1 change: 1 addition & 0 deletions packages/react-native/ios/NativeScriptUIView.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
@interface NativeScriptUIView : UIView

@property(nonatomic, copy) NSString* nativeViewHandle;
@property(nonatomic, copy) NSString* debugName;

@end
23 changes: 23 additions & 0 deletions packages/react-native/ios/NativeScriptUIView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ - (void)dealloc {
[_nativeView removeFromSuperview];
[_nativeView release];
[_nativeViewHandle release];
[_debugName release];
[super dealloc];
}

Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/react-native/ios/NativeScriptUIViewManager.mm
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ - (UIView*)view {
}

RCT_EXPORT_VIEW_PROPERTY(nativeViewHandle, NSString)
RCT_EXPORT_VIEW_PROPERTY(debugName, NSString)

@end
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNati

export interface NativeProps extends ViewProps {
nativeViewHandle?: string;
debugName?: string;
}

export default codegenNativeComponent<NativeProps>(
Expand Down
15 changes: 15 additions & 0 deletions packages/react-native/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,21 @@ export type InstallOptions = {
};

export type UIKitViewDefinition<Props extends object, NativeView = unknown> = {
/**
* 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<Props & ViewProps>) => NativeView;
update?: (
Expand Down
11 changes: 10 additions & 1 deletion packages/react-native/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export type InstallOptions = {
};

export type UIKitViewDefinition<Props extends object, NativeView = unknown> = {
name?: string;
debugName?: string;
displayName?: string;
create: (props: Readonly<Props & ViewProps>) => NativeView;
update?: (
Expand Down Expand Up @@ -680,6 +682,12 @@ export function runOnUI(callback?: () => void): Promise<void> {
export function defineUIKitView<Props extends object, NativeView = unknown>(
definition: UIKitViewDefinition<Props, NativeView>,
): UIKitViewComponent<Props, NativeView> {
const debugName =
definition.debugName
|| definition.name
|| definition.displayName
|| 'NativeScriptUIKitView';

const Component = forwardRef<UIKitViewRef<NativeView>, Props & ViewProps>(
function NativeScriptUIKitView(props, ref) {
const {nativeProps, pluginProps} = splitUIKitViewProps(props, definition);
Expand Down Expand Up @@ -804,12 +812,13 @@ export function defineUIKitView<Props extends object, NativeView = unknown>(
return React.createElement(NativeScriptUIViewNativeComponent, {
...nativeProps,
collapsable: false,
debugName,
nativeViewHandle,
});
},
);

Component.displayName = definition.displayName ?? 'NativeScriptUIKitView';
Component.displayName = definition.displayName || definition.name || debugName;
return Component;
}

Expand Down
6 changes: 5 additions & 1 deletion test/react-native/ffi-compat/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'))({
Expand Down Expand Up @@ -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');
Expand Down
14 changes: 14 additions & 0 deletions test/runtime/runner/app/tests/Marshalling/ObjCTypesTests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading