diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0a5aa548..6d62dbebf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,5 +53,6 @@ jobs: IOS_TEST_TIMEOUT_MS: "600000" IOS_TEST_INACTIVITY_TIMEOUT_MS: "180000" IOS_LOG_JUNIT: "1" + IOS_TEST_VERBOSE_SPECS: "1" IOS_SIMCTL_QUERY_TIMEOUT_MS: "10000" run: npm run test:ios diff --git a/NativeScript/CMakeLists.txt b/NativeScript/CMakeLists.txt index 518bf735a..e9452cdd0 100644 --- a/NativeScript/CMakeLists.txt +++ b/NativeScript/CMakeLists.txt @@ -324,6 +324,13 @@ if(ENABLE_JS_RUNTIME) runtime/apple/modules/web/Web.mm runtime/apple/NativeScript.mm runtime/apple/RuntimeConfig.cpp + # resolveMainPath() (cli/BundleLoader.h) is called directly from + # NativeScript.mm's runMainApplication, so it must be compiled into every + # framework/app target that includes NativeScript.mm -- not only the + # BUILD_CLI_BINARY executable. cli/main.cpp and cli/segappend.cpp stay + # CLI-binary-only below (main.cpp defines main(), which cannot also be + # linked into the NativeScript shared library). + cli/BundleLoader.mm runtime/modules/url/ada/ada.cpp runtime/modules/url/URL.cpp runtime/modules/url/URLSearchParams.cpp @@ -450,7 +457,6 @@ if(BUILD_CLI_BINARY) set(SOURCE_FILES ${SOURCE_FILES} cli/main.cpp cli/segappend.cpp - cli/BundleLoader.mm ) endif() diff --git a/NativeScript/cli/BundleLoader.mm b/NativeScript/cli/BundleLoader.mm index 6fc249579..14be4d875 100644 --- a/NativeScript/cli/BundleLoader.mm +++ b/NativeScript/cli/BundleLoader.mm @@ -1,50 +1,148 @@ #include "BundleLoader.h" #include +#include +#include // Check if Resources/app/ exists, then load package.json["main"] || app/index.js full file path -std::string resolveMainPath() { +static NSString* resourcesPathForExecutable(NSString* executablePath) { + if (executablePath == nil || [executablePath length] == 0) { + return nil; + } + + NSString* standardizedPath = [executablePath stringByStandardizingPath]; + NSString* macOSPath = [standardizedPath stringByDeletingLastPathComponent]; + NSString* contentsPath = [macOSPath stringByDeletingLastPathComponent]; + if ([[macOSPath lastPathComponent] isEqualToString:@"MacOS"] && + [[contentsPath lastPathComponent] isEqualToString:@"Contents"]) { + return [contentsPath stringByAppendingPathComponent:@"Resources"]; + } + + return nil; +} + +static bool shouldLogBundleResolution() { + return getenv("NS_BUNDLE_LOADER_DEBUG") != nullptr; +} + +static void addCandidatePath(NSMutableArray* candidates, NSString* path) { + if (path == nil || [path length] == 0) { + return; + } + + NSString* standardizedPath = [path stringByStandardizingPath]; + if (![candidates containsObject:standardizedPath]) { + [candidates addObject:standardizedPath]; + } +} + +static std::string resolveMainPathInResources(NSString* resourcesPath) { NSFileManager* fileManager = [NSFileManager defaultManager]; - NSString* resourcesPath = [[NSBundle mainBundle] resourcePath]; NSString* appPath = [resourcesPath stringByAppendingPathComponent:@"app"]; BOOL isDir; if ([fileManager fileExistsAtPath:appPath isDirectory:&isDir] && isDir) { + if (shouldLogBundleResolution()) { + NSLog(@"NativeScript BundleLoader checking app path: %@", appPath); + } NSString* packageJsonPath = [appPath stringByAppendingPathComponent:@"package.json"]; if ([fileManager fileExistsAtPath:packageJsonPath]) { NSData* jsonData = [NSData dataWithContentsOfFile:packageJsonPath]; - NSError* error; + NSError* error = nil; NSDictionary* packageDict = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:&error]; if (error == nil) { NSString* mainEntry = packageDict[@"main"]; + if (shouldLogBundleResolution()) { + NSLog(@"NativeScript BundleLoader package main: %@ from %@", mainEntry, packageJsonPath); + } if (mainEntry != nil) { NSString* mainPath = [appPath stringByAppendingPathComponent:mainEntry]; if ([fileManager fileExistsAtPath:mainPath]) { + if (shouldLogBundleResolution()) { + NSLog(@"NativeScript BundleLoader resolved main: %@", mainPath); + } return std::string([mainPath UTF8String]); } if ([[mainEntry pathExtension] length] == 0) { NSString* mainPathMjs = [mainPath stringByAppendingPathExtension:@"mjs"]; if ([fileManager fileExistsAtPath:mainPathMjs]) { + if (shouldLogBundleResolution()) { + NSLog(@"NativeScript BundleLoader resolved main: %@", mainPathMjs); + } return std::string([mainPathMjs UTF8String]); } NSString* mainPathJs = [mainPath stringByAppendingPathExtension:@"js"]; if ([fileManager fileExistsAtPath:mainPathJs]) { + if (shouldLogBundleResolution()) { + NSLog(@"NativeScript BundleLoader resolved main: %@", mainPathJs); + } return std::string([mainPathJs UTF8String]); } } } + } else if (shouldLogBundleResolution()) { + NSLog(@"NativeScript BundleLoader failed to parse %@: %@", packageJsonPath, error); } } // Fallback to app/index.js NSString* indexPath = [appPath stringByAppendingPathComponent:@"index.js"]; if ([fileManager fileExistsAtPath:indexPath]) { + if (shouldLogBundleResolution()) { + NSLog(@"NativeScript BundleLoader resolved fallback main: %@", indexPath); + } return std::string([indexPath UTF8String]); } + } else if (shouldLogBundleResolution()) { + NSLog(@"NativeScript BundleLoader skipped resources path: %@ appPath=%@ exists=%d isDir=%d", + resourcesPath, + appPath, + [fileManager fileExistsAtPath:appPath], + isDir); + } + + return ""; +} + +std::string resolveMainPath() { + NSMutableArray* candidates = [NSMutableArray array]; + addCandidatePath(candidates, [[NSBundle mainBundle] resourcePath]); + addCandidatePath(candidates, resourcesPathForExecutable([[NSBundle mainBundle] executablePath])); + + NSArray* arguments = [[NSProcessInfo processInfo] arguments]; + if ([arguments count] > 0) { + addCandidatePath(candidates, resourcesPathForExecutable([arguments objectAtIndex:0])); + } + + uint32_t executablePathLength = 0; + _NSGetExecutablePath(nullptr, &executablePathLength); + if (executablePathLength > 0) { + char* executablePathBuffer = static_cast(malloc(executablePathLength)); + if (executablePathBuffer != nullptr) { + if (_NSGetExecutablePath(executablePathBuffer, &executablePathLength) == 0) { + addCandidatePath(candidates, resourcesPathForExecutable([NSString stringWithUTF8String:executablePathBuffer])); + } + free(executablePathBuffer); + } + } + + NSString* currentDirectory = [[NSFileManager defaultManager] currentDirectoryPath]; + addCandidatePath(candidates, currentDirectory); + addCandidatePath(candidates, [currentDirectory stringByAppendingPathComponent:@"Resources"]); + addCandidatePath(candidates, [[currentDirectory stringByDeletingLastPathComponent] stringByAppendingPathComponent:@"Resources"]); + + for (NSString* resourcesPath in candidates) { + if (shouldLogBundleResolution()) { + NSLog(@"NativeScript BundleLoader candidate resources: %@", resourcesPath); + } + std::string mainPath = resolveMainPathInResources(resourcesPath); + if (!mainPath.empty()) { + return mainPath; + } } return ""; diff --git a/NativeScript/ffi/objc/hermes/NativeApiJsi.mm b/NativeScript/ffi/objc/hermes/NativeApiJsi.mm index 6039fb8a4..95a170d14 100644 --- a/NativeScript/ffi/objc/hermes/NativeApiJsi.mm +++ b/NativeScript/ffi/objc/hermes/NativeApiJsi.mm @@ -41,6 +41,8 @@ @protocol NativeApiClassBuilderProtocol extern const unsigned char embedded_metadata[EMBED_METADATA_SIZE]; #endif +#include "../shared/bridge/InteropProfiler.h" + namespace nativescript { namespace { @@ -111,7 +113,12 @@ bool tryCallGeneratedEngineObjCSelector( auto invoker = reinterpret_cast(prepared.engineInvoker); GsdObjCContext ctx{runtime, bridge, receiver, prepared.selector, args, prepared.signature.returnType}; - if (!invoker(ctx)) { + bool invoked; + { + NativeScriptInteropCallTimer nsInteropTimer; + invoked = invoker(ctx); + } + if (!invoked) { return false; } *result = std::move(ctx.result); @@ -144,7 +151,26 @@ NativeApiSelectorGroupState state( } if (state.boundReceiverState != nullptr) { receiverHostObject = state.boundReceiver.lock(); - } else if (thisValue.isObject()) { + if (receiverHostObject) { + return receiverHostObject; + } + // The bound receiver's wrapper has already been torn down (its + // owning JS proxy was collected) since this selector-group + // function was minted and cached as a native-object expando + // (Object.mm's `bridge_->setObjectExpando(..., methodFunction)`). + // The expando itself is keyed by the native pointer and survives + // wrapper churn, so a LATER crossing that re-wraps the SAME + // native object in a fresh `NativeApiObjectHostObject` (this + // runtime mints a new wrapper per crossing) finds the stale + // cached function still bound to the dead original -- every call + // through it then resolves a nil receiver and throws "Objective-C + // selector requires a native receiver" even though the method is + // being invoked on a perfectly live object right now. Fall + // through to `thisValue` exactly like the unbound path below: + // this IS a method call (`receiver.method(...)`), so `thisValue` + // is always the correct, live receiver for this invocation. + } + if (thisValue.isObject()) { Object receiverObject = thisValue.asObject(runtime); if (receiverObject.isHostObject( runtime)) { @@ -172,16 +198,26 @@ NativeApiSelectorGroupState state( // GSD fast path: read jsi args directly, call objc_msgSend with a // typed cast, produce the jsi return value — bypassing all generic // marshalling. Only engages for plain calls (no super dispatch, init - // disown handling, or implicit NSError-out argument). + // disown handling, implicit NSError-out argument, or appearance + // static selector — those need the generic path's proxy tagging). if (call.prepared->gsdEngineCallable && call.dispatchClass == Nil && count == call.prepared->gsdEngineArgumentCount && - !(!state.receiverIsClass && call.prepared->isInitMethod)) { + !(!state.receiverIsClass && call.prepared->isInitMethod) && + call.gsdAllowed) { auto invoker = reinterpret_cast(call.prepared->engineInvoker); GsdObjCContext ctx{runtime, state.bridge, call.receiver, call.prepared->selector, args, call.prepared->signature.returnType}; - if (invoker(ctx)) { + bool gsdInvoked; + { + NativeScriptInteropCallTimer nsInteropTimer; + gsdInvoked = invoker(ctx); + } + if (gsdInvoked) { + cachePreparedAppearanceProxySetterValue( + runtime, state.bridge, call.receiver, *call.prepared, args, + count); return std::move(ctx.result); } } @@ -198,8 +234,15 @@ NativeApiSelectorGroupState state( throw JSError(runtime, "Objective-C selector requires a native receiver."); } - return receiverHostObject->callPreparedObjectSelector( + Value result = receiverHostObject->callPreparedObjectSelector( runtime, *call.prepared, args, count, call.dispatchClass); + if (!state.receiverIsClass && call.prepared->isInitMethod) { + if (auto preserved = preservedNativeApiInitializerSelfReturn( + runtime, state.bridge, call.receiver, result, thisValue)) { + return std::move(*preserved); + } + } + return result; }); } diff --git a/NativeScript/ffi/objc/hermes/NativeApiJsiReactNative.h b/NativeScript/ffi/objc/hermes/NativeApiJsiReactNative.h index e7619bb0a..66fb752cc 100644 --- a/NativeScript/ffi/objc/hermes/NativeApiJsiReactNative.h +++ b/NativeScript/ffi/objc/hermes/NativeApiJsiReactNative.h @@ -62,7 +62,10 @@ inline NativeApiJsiConfig MakeReactNativeNativeApiJsiConfig( config.metadataPath = metadataPath; config.metadataPtr = metadataPtr; config.globalName = globalName; - config.installGlobalSymbols = true; + // RN launch cost: don't eagerly install the aggregate global surface or + // realize every class/protocol runtime pointer at symbol-index time. + config.installGlobalSymbols = false; + config.indexRuntimePointers = false; config.invokeCallbacksOnNativeCallerThread = true; config.scheduler = std::make_shared( std::move(jsInvoker), std::move(uiInvoker)); diff --git a/NativeScript/ffi/objc/jsc/NativeApiJSC.mm b/NativeScript/ffi/objc/jsc/NativeApiJSC.mm index 227af4c60..1bd88ade6 100644 --- a/NativeScript/ffi/objc/jsc/NativeApiJSC.mm +++ b/NativeScript/ffi/objc/jsc/NativeApiJSC.mm @@ -5,6 +5,8 @@ #include "NativeApiJSCRuntime.h" #include "SignatureDispatch.h" +#include "../shared/bridge/InteropProfiler.h" + namespace nativescript { namespace { diff --git a/NativeScript/ffi/objc/jsc/NativeApiJSCSelectorGroups.mm b/NativeScript/ffi/objc/jsc/NativeApiJSCSelectorGroups.mm index 1020e8cf2..1b677eacd 100644 --- a/NativeScript/ffi/objc/jsc/NativeApiJSCSelectorGroups.mm +++ b/NativeScript/ffi/objc/jsc/NativeApiJSCSelectorGroups.mm @@ -67,14 +67,21 @@ throw JSError( // GSD fast path: the generated invoker reads args directly from the JSC // arguments, calls objc_msgSend with a typed cast, and produces the JS - // return value — bypassing all generic marshalling. + // return value — bypassing all generic marshalling. Excludes appearance + // static selectors — those need the generic path's proxy tagging. if (prepared.gsdEngineCallable && dispatchSuperClass == Nil && providedCount == prepared.gsdEngineArgumentCount && - !initializerClassWrapper && !isNSErrorOutMethod) { + !initializerClassWrapper && !isNSErrorOutMethod && + !isPreparedStaticAppearanceSelector(prepared)) { auto invoker = reinterpret_cast(prepared.engineInvoker); GsdObjCContext ctx{runtime, bridge, receiver, prepared.selector, runtime.context(), arguments, signature.returnType}; if (invoker(ctx)) { + if (providedCount > 0) { + Value setterValue = Value::borrowed(runtime, arguments[0]); + cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver, + prepared, &setterValue, 1); + } return ctx.result; } } @@ -89,6 +96,11 @@ throw JSError( if (tryCallFastEngineObjCSelector(runtime, bridge, receiver, prepared, fastArgs, providedCount, Nil, &fastResult)) { + cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver, + prepared, fastArgs, + providedCount); + fastResult = tagPreparedStaticAppearanceSelectorResult( + runtime, bridge, receiver, prepared, std::move(fastResult)); return fastResult.local(runtime); } } @@ -159,6 +171,11 @@ NativeApiReturnStorage returnStorage( throw JSError( runtime, errorMessage != nullptr ? errorMessage : "Unknown NSError"); } + if (providedCount > 0) { + Value setterValue = Value::borrowed(runtime, arguments[0]); + cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver, + prepared, &setterValue, 1); + } if (initializerClassWrapper) { id resultObject = nil; if (isObjectiveCObjectType(returnType)) { @@ -173,6 +190,8 @@ throw JSError( Value(runtime, *initializerClassWrapper)); } } + tagPreparedStaticAppearanceNativeReturn( + runtime, bridge, receiver, prepared, returnType, returnStorage.data()); return setJSCEngineReturnValue(runtime, bridge, returnType, returnStorage.data(), prepared.selectorName); } @@ -227,10 +246,19 @@ JSValueRef NativeApiSelectorGroupCall( if (call.hasImmediateResult) { return call.immediateResult.local(runtime); } - return setJSCEnginePreparedObjCResult( + JSValueRef result = setJSCEnginePreparedObjCResult( runtime, data->bridge, call.receiver, *call.prepared, call.receiverHostObject, call.initializerClassWrapper, argumentCount, arguments, call.dispatchClass); + if (!data->receiverIsClass && call.prepared->isInitMethod && + thisObject != nullptr) { + if (auto preserved = preservedNativeApiInitializerSelfReturn( + runtime, data->bridge, call.receiver, Value::borrowed(runtime, result), + Value::borrowed(runtime, thisObject))) { + return preserved->local(runtime); + } + } + return result; } catch (const std::exception& error) { engine::jscengine::setException(context, exception, error); return JSValueMakeUndefined(context); diff --git a/NativeScript/ffi/objc/quickjs/NativeApiQuickJS.mm b/NativeScript/ffi/objc/quickjs/NativeApiQuickJS.mm index 8f234d1bd..f9fa9f468 100644 --- a/NativeScript/ffi/objc/quickjs/NativeApiQuickJS.mm +++ b/NativeScript/ffi/objc/quickjs/NativeApiQuickJS.mm @@ -5,6 +5,8 @@ #include "NativeApiQuickJSRuntime.h" #include "SignatureDispatch.h" +#include "../shared/bridge/InteropProfiler.h" + namespace nativescript { namespace { diff --git a/NativeScript/ffi/objc/quickjs/NativeApiQuickJSSelectorGroups.mm b/NativeScript/ffi/objc/quickjs/NativeApiQuickJSSelectorGroups.mm index bb9fe56c2..d9dd63438 100644 --- a/NativeScript/ffi/objc/quickjs/NativeApiQuickJSSelectorGroups.mm +++ b/NativeScript/ffi/objc/quickjs/NativeApiQuickJSSelectorGroups.mm @@ -68,14 +68,21 @@ throw JSError( // GSD fast path: the generated invoker reads args directly from the QuickJS // arguments, calls objc_msgSend with a typed cast, and produces the JS - // return value — bypassing all generic marshalling. + // return value — bypassing all generic marshalling. Excludes appearance + // static selectors — those need the generic path's proxy tagging. if (prepared.gsdEngineCallable && dispatchSuperClass == Nil && providedCount == prepared.gsdEngineArgumentCount && - !initializerClassWrapper && !isNSErrorOutMethod) { + !initializerClassWrapper && !isNSErrorOutMethod && + !isPreparedStaticAppearanceSelector(prepared)) { auto invoker = reinterpret_cast(prepared.engineInvoker); GsdObjCContext ctx{runtime, bridge, receiver, prepared.selector, runtime.context(), arguments, signature.returnType}; if (invoker(ctx)) { + if (providedCount > 0) { + Value setterValue = Value::borrowed(runtime, arguments[0]); + cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver, + prepared, &setterValue, 1); + } return ctx.result; } } @@ -90,6 +97,11 @@ throw JSError( if (tryCallFastEngineObjCSelector(runtime, bridge, receiver, prepared, fastArgs, providedCount, Nil, &fastResult)) { + cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver, + prepared, fastArgs, + providedCount); + fastResult = tagPreparedStaticAppearanceSelectorResult( + runtime, bridge, receiver, prepared, std::move(fastResult)); return fastResult.local(runtime); } } @@ -161,6 +173,11 @@ NativeApiReturnStorage returnStorage( throw JSError( runtime, errorMessage != nullptr ? errorMessage : "Unknown NSError"); } + if (providedCount > 0) { + Value setterValue = Value::borrowed(runtime, arguments[0]); + cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver, + prepared, &setterValue, 1); + } if (initializerClassWrapper) { id resultObject = nil; if (isObjectiveCObjectType(returnType)) { @@ -175,6 +192,8 @@ throw JSError( Value(runtime, *initializerClassWrapper)); } } + tagPreparedStaticAppearanceNativeReturn( + runtime, bridge, receiver, prepared, returnType, returnStorage.data()); return setQuickJSEngineReturnValue(runtime, bridge, returnType, returnStorage.data(), prepared.selectorName); @@ -236,10 +255,19 @@ JSValue NativeApiSelectorGroupCall(JSContext* context, JSValue thisValue, if (call.hasImmediateResult) { return call.immediateResult.local(runtime); } - return setQuickJSEnginePreparedObjCResult( + JSValue result = setQuickJSEnginePreparedObjCResult( runtime, data->bridge, call.receiver, *call.prepared, call.receiverHostObject, call.initializerClassWrapper, count, argv, call.dispatchClass); + if (!data->receiverIsClass && call.prepared->isInitMethod) { + if (auto preserved = preservedNativeApiInitializerSelfReturn( + runtime, data->bridge, call.receiver, Value::borrowed(runtime, result), + Value::borrowed(runtime, thisValue))) { + JS_FreeValue(context, result); + return preserved->local(runtime); + } + } + return result; } catch (const std::exception& error) { return engine::quickjsengine::throwError(context, error); } diff --git a/NativeScript/ffi/objc/shared/NativeApiBackendConfig.h b/NativeScript/ffi/objc/shared/NativeApiBackendConfig.h index a6c2044e8..859526f05 100644 --- a/NativeScript/ffi/objc/shared/NativeApiBackendConfig.h +++ b/NativeScript/ffi/objc/shared/NativeApiBackendConfig.h @@ -23,8 +23,10 @@ struct NativeApiBackendConfig { std::function)> runtimeCallbackInvoker = nullptr; std::function)> jsThreadCallbackInvoker = nullptr; std::function)> jsThreadAsyncCallbackInvoker = nullptr; + std::function callbackInvocationAllowed = nullptr; bool invokeCallbacksOnNativeCallerThread = false; bool installGlobalSymbols = false; + bool indexRuntimePointers = true; }; } // namespace nativescript diff --git a/NativeScript/ffi/objc/shared/bridge/Callbacks.mm b/NativeScript/ffi/objc/shared/bridge/Callbacks.mm index 179fbdf9b..66002a245 100644 --- a/NativeScript/ffi/objc/shared/bridge/Callbacks.mm +++ b/NativeScript/ffi/objc/shared/bridge/Callbacks.mm @@ -44,6 +44,21 @@ explicit NativeApiRuntimeScope(Runtime&) {} Runtime, }; +// Per-method callback policy, attached to a JS function via +// NativeScriptRuntime.nativeMethodPolicy(fn, policy) (a `__nativeScriptMethodPolicy` +// expando read back in readEngineMethodCallbackPolicy below). Deliberately +// small: the only live consumers are (a) calling the ObjC super +// implementation before the JS override runs, and (b) suppressing a +// re-entrant callback while a native accessor/construction call is already +// in flight (see nativeAccessorCallbackPolicy and +// shouldSkipConstructingMethodCallback). +struct NativeApiMethodCallbackPolicy { + bool callSuperBeforeCallback = false; + // Associated-object keys checked on the callback's receiver; if any is + // truthy, the callback is skipped entirely (zero-returned). + std::vector skipCallbackIfAssociatedObjectTruthy; +}; + NativeApiCallbackThreadPolicy readEngineCallbackThreadPolicy( Runtime& runtime, Object& functionObject) { constexpr const char* propertyName = "__nativeScriptCallbackThread"; @@ -67,6 +82,91 @@ NativeApiCallbackThreadPolicy readEngineCallbackThreadPolicy( return NativeApiCallbackThreadPolicy::Default; } +Value optionalObjectProperty(Runtime& runtime, Object& object, + const char* name) { + if (name == nullptr || !object.hasProperty(runtime, name)) { + return Value::undefined(); + } + return object.getProperty(runtime, name); +} + +void appendEngineMethodPolicyAssociatedObjectKeys( + Runtime& runtime, const Value& value, std::vector& keys) { + if (value.isString()) { + keys.push_back(value.asString(runtime).utf8(runtime)); + return; + } + if (!value.isObject()) { + return; + } + Object object = value.asObject(runtime); + if (!object.isArray(runtime)) { + return; + } + Array array = object.getArray(runtime); + size_t size = array.size(runtime); + for (size_t i = 0; i < size; i++) { + Value item = array.getValueAtIndex(runtime, i); + if (item.isString()) { + keys.push_back(item.asString(runtime).utf8(runtime)); + } + } +} + +NativeApiMethodCallbackPolicy readEngineMethodCallbackPolicyValue( + Runtime& runtime, const Value& policyValue) { + NativeApiMethodCallbackPolicy policy; + try { + if (!policyValue.isObject()) { + return policy; + } + + Object policyObject = policyValue.asObject(runtime); + Value callSuperBeforeValue = optionalObjectProperty( + runtime, policyObject, "callSuperBeforeCallback"); + if (callSuperBeforeValue.isBool() && callSuperBeforeValue.getBool()) { + policy.callSuperBeforeCallback = true; + } else { + Value callSuperValue = + optionalObjectProperty(runtime, policyObject, "callSuper"); + if (callSuperValue.isString()) { + policy.callSuperBeforeCallback = + callSuperValue.asString(runtime).utf8(runtime) == "before"; + } + } + + appendEngineMethodPolicyAssociatedObjectKeys( + runtime, + optionalObjectProperty( + runtime, policyObject, "skipCallbackIfAssociatedObjectTruthy"), + policy.skipCallbackIfAssociatedObjectTruthy); + } catch (const std::exception&) { + return NativeApiMethodCallbackPolicy{}; + } + return policy; +} + +// Reads the policy a JS function was tagged with via +// NativeScriptRuntime.nativeMethodPolicy(fn, policy). +NativeApiMethodCallbackPolicy readEngineMethodCallbackPolicy( + Runtime& runtime, Object& functionObject) { + constexpr const char* propertyName = "__nativeScriptMethodPolicy"; + try { + if (!functionObject.hasProperty(runtime, propertyName)) { + return NativeApiMethodCallbackPolicy{}; + } + return readEngineMethodCallbackPolicyValue( + runtime, functionObject.getProperty(runtime, propertyName)); + } catch (const std::exception&) { + return NativeApiMethodCallbackPolicy{}; + } +} + +bool isEmptyMethodCallbackPolicy(const NativeApiMethodCallbackPolicy& policy) { + return !policy.callSuperBeforeCallback && + policy.skipCallbackIfAssociatedObjectTruthy.empty(); +} + bool selectorEndsWithNSErrorParam(const std::string& selectorName) { constexpr const char* suffix = "error:"; size_t suffixLength = std::strlen(suffix); @@ -391,7 +491,9 @@ Function persistentEngineFunction(Runtime& runtime, const Function& function) { NativeApiCallbackThreadPolicy threadPolicy = NativeApiCallbackThreadPolicy::Default, bool bindThis = false, - uintptr_t roundTripValidationKey = 0) + uintptr_t roundTripValidationKey = 0, + NativeApiMethodCallbackPolicy methodPolicy = {}, + Class methodBaseClass = Nil) : runtimeOwner_(retainNativeApiRuntime(runtime)), runtime_(runtimeOwner_.get()), bridge_(std::move(bridge)), @@ -401,7 +503,9 @@ Function persistentEngineFunction(Runtime& runtime, const Function& function) { block_(block), threadPolicy_(threadPolicy), bindThis_(bindThis), - roundTripValidationKey_(roundTripValidationKey) { + roundTripValidationKey_(roundTripValidationKey), + methodPolicy_(std::move(methodPolicy)), + methodBaseClass_(methodBaseClass) { closure_ = static_cast( ffi_closure_alloc(sizeof(ffi_closure), &executable_)); if (closure_ == nullptr || executable_ == nullptr || @@ -580,6 +684,30 @@ void invoke(void* ret, void* args[]) { throwNativeApiCallbackException("Invalid callback."); } + // Teardown-safety gate: once the host has signaled that callbacks are no + // longer allowed (runtime shutting down/reloading), bail with a + // zeroed return instead of running JS against a dying runtime. + bool callbackAllowed = false; + if (bridge_ != nullptr) { + @try { + callbackAllowed = bridge_->callbackInvocationAllowed(); + } @catch (...) { + callbackAllowed = false; + } + } + + if (!callbackAllowed) { + zeroReturnValue(ret); + return; + } + + if (methodPolicy_.callSuperBeforeCallback) { + invokeMethodSuper(ret, args); + } + if (shouldSkipMethodCallback(args, ret)) { + return; + } + std::string error; auto call = [&]() { invokeOnCurrentThread(ret, args, &error); }; const auto& nativeCallbackInvoker = bridge_->nativeCallbackInvoker(); @@ -734,6 +862,158 @@ void invoke(void* ret, void* args[]) { } private: + // The ObjC class whose implementation `callSuperBeforeCallback`/ + // invokeMethodSuper() should dispatch against. A JS-subclass receiver + // (ClassBuilder instance) dispatches to its own runtime superclass; + // anything else falls back to methodBaseClass_ (the class the override was + // registered against). + Class dispatchSuperclassForMethodReceiver(id receiver) const { + if (receiver == nil) { + return Nil; + } + + Class receiverClass = object_getClass(receiver); + if (receiverClass != Nil && + class_conformsToProtocol(receiverClass, + @protocol(NativeApiClassBuilderProtocol))) { + Class superclass = class_getSuperclass(receiverClass); + if (superclass != Nil) { + return superclass; + } + } + + return methodBaseClass_; + } + + // Method callback policies only ever target the callback's receiver (no + // argument-index targeting), so this is just the bound `self`. + id methodCallbackReceiver(void* args[]) const { + if (!bindThis_ || args == nullptr) { + return nil; + } + return *static_cast(args[0]); + } + + id associatedObjectValue(id receiver, const std::string& key) const { + if (receiver == nil || key.empty()) { + return nil; + } + return objc_getAssociatedObject(receiver, sel_registerName(key.c_str())); + } + + bool associatedObjectIsTruthy(id receiver, const std::string& key) const { + id value = associatedObjectValue(receiver, key); + if (value == nil) { + return false; + } + + if ([value respondsToSelector:@selector(boolValue)]) { + return [value boolValue] == YES; + } + if ([value isKindOfClass:[NSString class]]) { + NSString* stringValue = (NSString*)value; + if (stringValue.length == 0) { + return false; + } + NSString* lowercase = [stringValue lowercaseString]; + return ![lowercase isEqualToString:@"0"] && + ![lowercase isEqualToString:@"false"] && + ![lowercase isEqualToString:@"no"]; + } + + return true; + } + + // Skips a re-entrant callback: an alloc/init construction already in + // flight (marked via __nativeApiConstructionState) must not have its + // non-init methods re-entered by a partially-constructed self. + bool shouldSkipConstructingMethodCallback(void* args[], void* ret) { + if (!bindThis_ || args == nullptr || signature_ == nullptr || + signature_->selectorName.rfind("init", 0) == 0) { + return false; + } + + id receiver = *static_cast(args[0]); + if (receiver == nil || + objc_getAssociatedObject( + receiver, sel_registerName("__nativeApiConstructionState")) == nil) { + return false; + } + + zeroReturnValue(ret); + return true; + } + + bool shouldSkipMethodCallback(void* args[], void* ret) { + if (args == nullptr) { + return false; + } + + if (shouldSkipConstructingMethodCallback(args, ret)) { + return true; + } + + id receiver = methodCallbackReceiver(args); + for (const auto& key : + methodPolicy_.skipCallbackIfAssociatedObjectTruthy) { + if (associatedObjectIsTruthy(receiver, key)) { + zeroReturnValue(ret); + return true; + } + } + + return false; + } + + // callSuperBeforeCallback: run the ObjC super implementation before the JS + // override does, via objc_msgSendSuper with the same arguments the JS + // callback is about to receive. + void invokeMethodSuper(void* ret, void* args[]) const { + if (!bindThis_ || args == nullptr || signature_ == nullptr || + methodBaseClass_ == Nil) { + return; + } + + id receiver = *static_cast(args[0]); + Class dispatchClass = dispatchSuperclassForMethodReceiver(receiver); + if (receiver == nil || dispatchClass == Nil) { + return; + } + + struct objc_super superReceiver = {receiver, dispatchClass}; + struct objc_super* superReceiverPtr = &superReceiver; + size_t nativeArgc = + signature_->implicitArgumentCount + signature_->argumentTypes.size(); + std::vector values(nativeArgc); + values[0] = &superReceiverPtr; + values[1] = args[1]; + for (size_t i = 2; i < nativeArgc; i++) { + values[i] = args[i]; + } + + std::vector returnStorage; + void* returnTarget = ret; + if (returnTarget == nullptr) { + returnStorage.resize( + std::max(nativeSizeForType(signature_->returnType), 1)); + returnTarget = returnStorage.data(); + } + + performNativeInvocation(*runtime_, bridge_->nativeInvocationInvoker(), [&]() { +#if defined(__x86_64__) + bool isStret = signature_->returnType.ffiType->size > 16 && + signature_->returnType.ffiType->type == FFI_TYPE_STRUCT; + void (*target)(void) = isStret ? FFI_FN(objc_msgSendSuper_stret) + : FFI_FN(objc_msgSendSuper); + ffi_call(const_cast(&signature_->cif), target, returnTarget, + values.data()); +#else + ffi_call(const_cast(&signature_->cif), + FFI_FN(objc_msgSendSuper), returnTarget, values.data()); +#endif + }); + } + void invokeOnCurrentThread(void* ret, void* args[], std::string* error) { try { NativeApiRuntimeScope runtimeScope(*runtime_); @@ -749,8 +1029,21 @@ void invokeOnCurrentThread(void* ret, void* args[], std::string* error) { Value result = Value::undefined(); if (bindThis_ && nativeArgOffset >= 1) { id self = *static_cast(args[0]); + // `this.super`/`$base` from inside this override should dispatch + // against the class ABOVE the one the override was registered on, + // not the receiver's own (possibly further-subclassed) runtime class. + // `methodBaseClass_` (threaded from ClassBuilder's addEngineOverrideMethod + // as `baseClass`, i.e. `class_getSuperclass(nativeClass)`) IS already + // that class -- it must be used directly, not further superclassed + // (which would skip straight past it to ITS superclass and make any + // member declared exactly on methodBaseClass_, e.g. a method the + // override shadows that isn't itself inherited, unreachable via + // `this.super`). dispatchSuperclassForMethodReceiver() above already + // relies on this same "use methodBaseClass_ as-is" convention. + Class superDispatchClass = methodBaseClass_; Value thisValue = - makeNativeObjectValue(*runtime_, bridge_, self, false); + makeNativeObjectValue( + *runtime_, bridge_, self, false, superDispatchClass); Object thisObject = thisValue.isObject() ? thisValue.asObject(*runtime_) : Object(*runtime_); @@ -880,6 +1173,8 @@ void storeReturnValue(const Value& result, void* ret) { NativeApiCallbackThreadPolicy::Default; bool bindThis_ = false; uintptr_t roundTripValidationKey_ = 0; + NativeApiMethodCallbackPolicy methodPolicy_; + Class methodBaseClass_ = Nil; ffi_closure* closure_ = nullptr; void* executable_ = nullptr; std::string blockSignature_; @@ -2214,7 +2509,8 @@ throw JSError( std::shared_ptr createEngineMethodCallback( Runtime& runtime, const std::shared_ptr& bridge, const std::string& selectorName, MDSectionOffset signatureOffset, - Function function, bool returnOwned) { + Function function, bool returnOwned, Class methodBaseClass = Nil, + NativeApiMethodCallbackPolicy methodPolicy = {}) { if (bridge == nullptr || bridge->metadata() == nullptr || signatureOffset == MD_SECTION_OFFSET_NULL) { throw JSError( @@ -2232,9 +2528,15 @@ throw JSError( auto signature = std::make_shared(std::move(*parsed)); auto threadPolicy = readEngineCallbackThreadPolicy(runtime, function); + // A policy passed explicitly by the caller (e.g. the auto-installed + // accessor/construction re-entry guards) wins; otherwise fall back to + // whatever the JS function itself was tagged with via nativeMethodPolicy(). + if (isEmptyMethodCallbackPolicy(methodPolicy)) { + methodPolicy = readEngineMethodCallbackPolicy(runtime, function); + } auto callback = std::make_shared( runtime, bridge, std::move(signature), std::move(function), false, - threadPolicy, true); + threadPolicy, true, 0, std::move(methodPolicy), methodBaseClass); bridge->retainEngineLifetime(callback); return callback; } @@ -2242,7 +2544,8 @@ throw JSError( std::shared_ptr createEngineMethodCallback( Runtime& runtime, const std::shared_ptr& bridge, const std::string& selectorName, NativeApiSignature signature, - Function function) { + Function function, Class methodBaseClass = Nil, + NativeApiMethodCallbackPolicy methodPolicy = {}) { signature.selectorName = selectorName; prepareEngineMethodSignature(&signature); if (!signatureSupportedForEngineCallback(signature)) { @@ -2253,9 +2556,12 @@ throw JSError( auto sharedSignature = std::make_shared(std::move(signature)); auto threadPolicy = readEngineCallbackThreadPolicy(runtime, function); + if (isEmptyMethodCallbackPolicy(methodPolicy)) { + methodPolicy = readEngineMethodCallbackPolicy(runtime, function); + } auto callback = std::make_shared( runtime, bridge, std::move(sharedSignature), std::move(function), false, - threadPolicy, true); + threadPolicy, true, 0, std::move(methodPolicy), methodBaseClass); bridge->retainEngineLifetime(callback); return callback; } diff --git a/NativeScript/ffi/objc/shared/bridge/ClassBuilder.mm b/NativeScript/ffi/objc/shared/bridge/ClassBuilder.mm index 8141e7c41..c3077cb0a 100644 --- a/NativeScript/ffi/objc/shared/bridge/ClassBuilder.mm +++ b/NativeScript/ffi/objc/shared/bridge/ClassBuilder.mm @@ -66,6 +66,82 @@ void rememberNativeApiKnownExposedMethod( return method; } +// An ObjC init convention lets an initializer return an object other than +// `self` (most commonly `self` itself, but a class cluster/singleton +// initializer can return a completely different receiver). When it returns +// the SAME receiver we already wrapped for this call, the JS side should keep +// resolving to that one preserved wrapper rather than creating (and briefly +// GC'ing) a second, divergent one for the identical native object — so the +// stale/duplicate wrapper here is detached (its bridge state, e.g. expandos, +// handed to the preserved wrapper) rather than left to tear down the shared +// native receiver's bridge state on its own destruction. +std::optional preservedNativeApiInitializerSelfReturn( + Runtime& runtime, const std::shared_ptr& bridge, + id receiver, const Value& result, const Value& receiverValue) { + if (bridge == nullptr || receiver == nil || !receiverValue.isObject()) { + return std::nullopt; + } + + id resultObject = + NativeApiObjectHostObject::nativeObjectFromValue(runtime, result); + if (resultObject != receiver) { + return std::nullopt; + } + + Object receiverObject = receiverValue.asObject(runtime); + if (!receiverObject.isHostObject(runtime)) { + return std::nullopt; + } + + auto receiverHostObject = + receiverObject.getHostObject(runtime); + if (receiverHostObject == nullptr || + receiverHostObject->object() != receiver) { + return std::nullopt; + } + + std::shared_ptr resultHostObject; + if (result.isObject()) { + Object resultObjectValue = result.asObject(runtime); + if (resultObjectValue.isHostObject(runtime)) { + resultHostObject = + resultObjectValue.getHostObject(runtime); + } + } + + if (resultHostObject != nullptr && resultHostObject != receiverHostObject) { + resultHostObject->detachObjectPreservingBridgeState(receiver); + } + + Value preserved(runtime, receiverValue); + bridge->rememberNativeObjectRoundTripValue(runtime, receiver, preserved); + return preserved; +} + +// $base/super dispatch wrapper for ClassBuilder subclasses: calls the ObjC +// super implementation, then — for initializers only — applies the +// preserved-self-return handling above. +Value callNativeApiBaseObjectSelector( + Runtime& runtime, const std::shared_ptr& bridge, + const Object& receiverObject, + const std::shared_ptr& receiverHostObject, + id receiver, const std::string& selectorName, + const NativeApiMember* member, const Value* args, size_t count, + Class dispatchClass) { + Value result = receiverHostObject->callObjectSelector( + runtime, selectorName, member, args, count, dispatchClass); + + if (selectorName.rfind("init", 0) != 0) { + return result; + } + + if (auto preserved = preservedNativeApiInitializerSelfReturn( + runtime, bridge, receiver, result, Value(runtime, receiverObject))) { + return std::move(*preserved); + } + return result; +} + std::optional findNativeApiClassBuilder(id object) { Class cls = object != nil ? object_getClass(object) : Nil; @@ -261,14 +337,16 @@ void addEngineOverrideMethod(Runtime& runtime, Class nativeClass, Class baseClass, const std::string& selectorName, MDSectionOffset signatureOffset, - bool returnOwned, Function function) { + bool returnOwned, Function function, + NativeApiMethodCallbackPolicy methodPolicy = {}) { if (selectorName.empty() || signatureOffset == MD_SECTION_OFFSET_NULL) { return; } auto callback = createEngineMethodCallback(runtime, bridge, selectorName, signatureOffset, std::move(function), - returnOwned); + returnOwned, baseClass, + std::move(methodPolicy)); SEL selector = sel_registerName(selectorName.c_str()); std::string metadataEncoding = objcMethodSignatureForEngineSignature(callback->signature()); @@ -284,6 +362,16 @@ Value getObjectPropertyOrUndefined(Runtime& runtime, const Object& object, : Value::undefined(); } +// Auto-applied to native accessor (getter/setter) overrides: suppresses +// re-entrancy while the accessor machinery is already dispatching through +// this same receiver (set/cleared around JS-subclass accessor invocation). +NativeApiMethodCallbackPolicy nativeAccessorCallbackPolicy( + NativeApiMethodCallbackPolicy policy = {}) { + policy.skipCallbackIfAssociatedObjectTruthy.push_back( + "__nativeApiAccessorCallbackState"); + return policy; +} + Class dispatchSuperclassForEngineDerivedReceiver(id receiver, Class defaultSuperclass) { if (receiver == nil) { @@ -438,12 +526,16 @@ throw JSError( void addEngineExposedMethod(Runtime& runtime, const std::shared_ptr& bridge, Class nativeClass, const std::string& selectorName, - NativeApiSignature signature, Function function) { + NativeApiSignature signature, Function function, + Class methodBaseClass = Nil, + NativeApiMethodCallbackPolicy methodPolicy = {}) { if (selectorName.empty()) { return; } auto callback = createEngineMethodCallback(runtime, bridge, selectorName, - std::move(signature), std::move(function)); + std::move(signature), std::move(function), + methodBaseClass, + std::move(methodPolicy)); std::string encoding = objcMethodSignatureForEngineSignature(callback->signature()); class_replaceMethod(nativeClass, sel_registerName(selectorName.c_str()), reinterpret_cast(callback->functionPointer()), @@ -627,7 +719,8 @@ throw JSError(runtime, addEngineExposedMethod(runtime, bridge, nativeClass, known->selectorName, std::move(known->signature), - value.asObject(runtime).asFunction(runtime)); + value.asObject(runtime).asFunction(runtime), + baseClass); } } } @@ -643,7 +736,8 @@ throw JSError(runtime, runtime, bridge, nativeClass, baseClass, propertyMember->selectorName, propertyMember->signatureOffset, (propertyMember->flags & metagen::mdMemberReturnOwned) != 0, - getter.asObject(runtime).asFunction(runtime)); + getter.asObject(runtime).asFunction(runtime), + nativeAccessorCallbackPolicy()); } else if (propertyMember == nullptr && getter.isObject() && getter.asObject(runtime).isFunction(runtime)) { auto overrides = methodOverridesForName(members, propertyName); @@ -655,7 +749,8 @@ throw JSError(runtime, runtime, bridge, nativeClass, baseClass, member.selectorName, member.signatureOffset, (member.flags & metagen::mdMemberReturnOwned) != 0, - getter.asObject(runtime).asFunction(runtime)); + getter.asObject(runtime).asFunction(runtime), + nativeAccessorCallbackPolicy()); } } @@ -666,7 +761,8 @@ throw JSError(runtime, addEngineOverrideMethod(runtime, bridge, nativeClass, baseClass, propertyMember->setterSelectorName, propertyMember->setterSignatureOffset, false, - setter.asObject(runtime).asFunction(runtime)); + setter.asObject(runtime).asFunction(runtime), + nativeAccessorCallbackPolicy()); } } @@ -699,7 +795,8 @@ throw JSError(runtime, if (signature) { rememberNativeApiKnownExposedMethod(selectorName, *signature); addEngineExposedMethod(runtime, bridge, nativeClass, selectorName, - std::move(*signature), std::move(*function)); + std::move(*signature), std::move(*function), + baseClass); } } } @@ -761,8 +858,9 @@ throw JSError( if (actualArgc == 0) { Class dispatchClass = dispatchSuperclassForEngineDerivedReceiver(receiver, baseClass); - return receiverHostObject->callObjectSelector( - runtime, propertyMember->selectorName, propertyMember, nullptr, 0, + return callNativeApiBaseObjectSelector( + runtime, bridge, receiverObject, receiverHostObject, receiver, + propertyMember->selectorName, propertyMember, nullptr, 0, dispatchClass); } if (actualArgc == 1 && !propertyMember->setterSelectorName.empty() && @@ -772,9 +870,10 @@ throw JSError( NativeApiMember setterMember = *propertyMember; setterMember.selectorName = propertyMember->setterSelectorName; setterMember.signatureOffset = propertyMember->setterSignatureOffset; - return receiverHostObject->callObjectSelector( - runtime, setterMember.selectorName, &setterMember, args + 3, - actualArgc, dispatchClass); + return callNativeApiBaseObjectSelector( + runtime, bridge, receiverObject, receiverHostObject, receiver, + setterMember.selectorName, &setterMember, args + 3, actualArgc, + dispatchClass); } } } @@ -785,7 +884,7 @@ throw JSError( Class dispatchClass = dispatchSuperclassForEngineDerivedReceiver(receiver, baseClass); - return receiverHostObject->callObjectSelector(runtime, member->selectorName, - member, args + 3, actualArgc, - dispatchClass); + return callNativeApiBaseObjectSelector( + runtime, bridge, receiverObject, receiverHostObject, receiver, + member->selectorName, member, args + 3, actualArgc, dispatchClass); } diff --git a/NativeScript/ffi/objc/shared/bridge/HostObject.mm b/NativeScript/ffi/objc/shared/bridge/HostObject.mm index d16a91f96..d70140a4f 100644 --- a/NativeScript/ffi/objc/shared/bridge/HostObject.mm +++ b/NativeScript/ffi/objc/shared/bridge/HostObject.mm @@ -316,6 +316,66 @@ throw JSError(runtime, return Value::undefined(); }); } + // Re-entry guards for JS-subclass instance construction/accessors, + // backed by an associated object (not a JS expando — see the + // memo/expando-proxy notes: expandos never round-trip on these + // proxies). ClassBuilder wires these around alloc/init and native + // accessor dispatch. + if (property == "__setObjectConstructionState") { + return Function::createFromHostFunction( + runtime, PropNameID::forAscii(runtime, "__setObjectConstructionState"), + 2, + [](Runtime& runtime, const Value&, const Value* args, + size_t count) -> Value { + if (count < 1) { + return Value::undefined(); + } + id object = NativeApiObjectHostObject::nativeObjectFromValue( + runtime, args[0]); + if (object == nil) { + return Value::undefined(); + } + bool constructing = + count >= 2 && args[1].isBool() && args[1].getBool(); + objc_setAssociatedObject( + object, sel_registerName("__nativeApiConstructionState"), + constructing ? @YES : nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + return Value::undefined(); + }); + } + if (property == "__setObjectAccessorCallbackState") { + // Depth-counted (not a bool) so nested/re-entrant accessor calls on the + // same object (e.g. a getter that reads another property) still clear + // correctly on unwind. + return Function::createFromHostFunction( + runtime, PropNameID::forAscii( + runtime, "__setObjectAccessorCallbackState"), + 2, + [](Runtime& runtime, const Value&, const Value* args, + size_t count) -> Value { + if (count < 1) { + return Value::undefined(); + } + id object = NativeApiObjectHostObject::nativeObjectFromValue( + runtime, args[0]); + if (object == nil) { + return Value::undefined(); + } + bool active = count >= 2 && args[1].isBool() && args[1].getBool(); + SEL key = sel_registerName("__nativeApiAccessorCallbackState"); + NSNumber* current = (NSNumber*)objc_getAssociatedObject(object, key); + NSInteger depth = current != nil ? current.integerValue : 0; + if (active) { + depth += 1; + } else if (depth > 0) { + depth -= 1; + } + objc_setAssociatedObject( + object, key, depth > 0 ? @(depth) : nil, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + return Value::undefined(); + }); + } if (property == "CC_SHA256") { auto bridge = bridge_; return Function::createFromHostFunction( @@ -525,6 +585,7 @@ throw JSError(runtime, addPropertyName(runtime, names, "__makeSelectorGroupFunction"); addPropertyName(runtime, names, "__rememberClassWrapper"); addPropertyName(runtime, names, "__rememberObjectClassWrapper"); + addPropertyName(runtime, names, "__setObjectConstructionState"); addPropertyName(runtime, names, "getFunction"); addPropertyName(runtime, names, "getConstant"); addPropertyName(runtime, names, "getEnum"); diff --git a/NativeScript/ffi/objc/shared/bridge/HostObjects.mm b/NativeScript/ffi/objc/shared/bridge/HostObjects.mm index d3e4dbfca..c54053916 100644 --- a/NativeScript/ffi/objc/shared/bridge/HostObjects.mm +++ b/NativeScript/ffi/objc/shared/bridge/HostObjects.mm @@ -47,6 +47,8 @@ void setObject(id object) { #include "host_objects/Struct.mm" +#include "host_objects/Appearance.mm" + #include "host_objects/Object.mm" #include "host_objects/Class.mm" diff --git a/NativeScript/ffi/objc/shared/bridge/Install.mm b/NativeScript/ffi/objc/shared/bridge/Install.mm index de8716fd9..67bfd84ef 100644 --- a/NativeScript/ffi/objc/shared/bridge/Install.mm +++ b/NativeScript/ffi/objc/shared/bridge/Install.mm @@ -237,6 +237,177 @@ function findPrototypeDescriptor(className, property) { return undefined; } + function setObjectAccessorCallbackState(instance, active) { + try { + if (typeof api.__setObjectAccessorCallbackState === 'function') { + api.__setObjectAccessorCallbackState(instance, !!active); + } + } catch (_) { + } + } + + function nativeExtensionAccessorWithCallbackState(fn) { + if (typeof fn !== 'function') { + return fn; + } + return function() { + setObjectAccessorCallbackState(this, true); + try { + var args = Array.prototype.slice.call(arguments); + return fn.apply(this, args); + } finally { + setObjectAccessorCallbackState(this, false); + } + }; + } + + // Wraps extend()'s methods object with: (a) the re-entry guard above around + // any accessor (get/set) so a native accessor invocation calling back into + // itself through the ObjC runtime is suppressed rather than recursing, and + // (b) NSFastEnumeration-flavored indexed-collection aliases + // (objectAtIndexedSubscript / setObjectAtIndexedSubscript / Symbol.iterator) + // when the methods object looks like an Obj-C indexed collection + // (objectAtIndex + count), matching what a hand-written ObjC subclass gets + // for free from the runtime. + function nativeExtensionMethodsWithIndexedCollectionAliases(methods) { + if (methods == null || typeof methods !== 'object') { + return methods; + } + + var descriptors = Object.getOwnPropertyDescriptors(methods); + var descriptorKeys = + typeof Reflect === 'object' && typeof Reflect.ownKeys === 'function' + ? Reflect.ownKeys(descriptors) + : Object.keys(descriptors); + var needsAccessorCallbackState = false; + for (var descriptorIndex = 0; descriptorIndex < descriptorKeys.length; descriptorIndex++) { + var descriptor = descriptors[descriptorKeys[descriptorIndex]]; + if (descriptor && + (typeof descriptor.get === 'function' || + typeof descriptor.set === 'function')) { + needsAccessorCallbackState = true; + break; + } + } + + var hasObjectAtIndex = + Object.prototype.hasOwnProperty.call(methods, 'objectAtIndex'); + var hasCount = + Object.prototype.hasOwnProperty.call(methods, 'count'); + var hasSymbolIterator = + typeof Symbol === 'function' && Symbol.iterator && + Object.prototype.hasOwnProperty.call(methods, Symbol.iterator); + var needsObjectAtIndexedSubscript = + hasObjectAtIndex && + !Object.prototype.hasOwnProperty.call(methods, 'objectAtIndexedSubscript'); + var needsSetObjectAtIndexedSubscript = + Object.prototype.hasOwnProperty.call(methods, 'replaceObjectAtIndexWithObject') && + !Object.prototype.hasOwnProperty.call(methods, 'setObjectAtIndexedSubscript'); + var needsIndexedCollectionIterator = + typeof Symbol === 'function' && Symbol.iterator && + hasObjectAtIndex && hasCount && !hasSymbolIterator; + + if (!needsObjectAtIndexedSubscript && + !needsSetObjectAtIndexedSubscript && + !needsIndexedCollectionIterator && + !needsAccessorCallbackState) { + return methods; + } + + if (needsAccessorCallbackState) { + for (var accessorIndex = 0; accessorIndex < descriptorKeys.length; accessorIndex++) { + var accessorKey = descriptorKeys[accessorIndex]; + var accessorDescriptor = descriptors[accessorKey]; + if (!accessorDescriptor) { + continue; + } + if (typeof accessorDescriptor.get === 'function') { + accessorDescriptor.get = + nativeExtensionAccessorWithCallbackState(accessorDescriptor.get); + } + if (typeof accessorDescriptor.set === 'function') { + accessorDescriptor.set = + nativeExtensionAccessorWithCallbackState(accessorDescriptor.set); + } + } + } + + var prepared = Object.create(Object.getPrototypeOf(methods)); + Object.defineProperties(prepared, descriptors); + + if (needsObjectAtIndexedSubscript) { + Object.defineProperty(prepared, 'objectAtIndexedSubscript', { + configurable: true, + enumerable: false, + writable: true, + value: function(index) { + return this.objectAtIndex(index); + } + }); + } + + if (needsSetObjectAtIndexedSubscript) { + Object.defineProperty(prepared, 'setObjectAtIndexedSubscript', { + configurable: true, + enumerable: false, + writable: true, + value: function(anObject, index) { + return this.replaceObjectAtIndexWithObject(index, anObject); + } + }); + } + + if (needsIndexedCollectionIterator) { + Object.defineProperty(prepared, Symbol.iterator, { + configurable: true, + enumerable: false, + writable: true, + value: function() { + var receiver = this; + var index = 0; + return { + next: function() { + var countValue = receiver.count; + var count = typeof countValue === 'function' + ? countValue.call(receiver) + : countValue; + if (!(index < count)) { + return { done: true }; + } + return { + value: receiver.objectAtIndex(index++), + done: false + }; + } + }; + } + }); + } + + return prepared; + } + + function nativeExtensionMethodsHaveIterator(methods) { + return typeof Symbol === 'function' && Symbol.iterator && + methods != null && typeof methods === 'object' && + Object.prototype.hasOwnProperty.call(methods, Symbol.iterator); + } + + function nativeExtensionOptionsWithIterator(options, methods) { + var extendOptions = options || {}; + if (!nativeExtensionMethodsHaveIterator(methods)) { + return extendOptions; + } + try { + return Object.assign({}, extendOptions, { + __hasIterator: true + }); + } catch (_) { + extendOptions.__hasIterator = true; + return extendOptions; + } + } + Object.defineProperty(globalThis, '__nativeScriptCreateNativeApiIterator', { configurable: false, enumerable: false, @@ -522,7 +693,31 @@ function unavailableInitializerError(error) { /Objective-C selector is not available/.test(String(error.message || error)); } - function constructNativeInstance(nativeClass, args, rememberInstance) { + // markConstructing is true for JS-subclass (ClassBuilder) instances: their + // alloc/init sequence marks the receiver as "under construction" so a + // non-init method callback landing on a partially-constructed self (e.g. + // from within an ObjC framework's own init machinery) is suppressed + // instead of re-entering JS with an object that isn't fully set up yet + // (see shouldSkipConstructingMethodCallback). + function shouldUseAllocInitConstructor(constructable, wrapper) { + var target = wrapper || constructable; + try { + return !!(target && target.__nativeApiUseAllocInitConstructor); + } catch (_) { + return false; + } + } + + function setObjectConstructionState(instance, constructing) { + try { + if (api && typeof api.__setObjectConstructionState === 'function') { + api.__setObjectConstructionState(instance, !!constructing); + } + } catch (_) { + } + } + + function constructNativeInstance(nativeClass, args, rememberInstance, markConstructing) { if (args.length === 1 && args[0] && typeof args[0] === 'object' && @@ -561,13 +756,16 @@ function constructNativeInstance(nativeClass, args, rememberInstance) { if (typeof rememberInstance === 'function') { instance = rememberInstance(instance); } - if (initializer.selectorName === 'init') { - if (typeof instance.init !== 'function') { - throw new Error('No initializer found that matches constructor invocation.'); - } - return instance.init(); + if (markConstructing) { + setObjectConstructionState(instance, true); } try { + if (initializer.selectorName === 'init') { + if (typeof instance.init !== 'function') { + throw new Error('No initializer found that matches constructor invocation.'); + } + return instance.init(); + } if (initializer.name && typeof instance[initializer.name] === 'function') { return instance[initializer.name](...actualArgs); } @@ -581,6 +779,73 @@ function constructNativeInstance(nativeClass, args, rememberInstance) { throw new Error('No initializer found that matches constructor invocation.'); } throw error; + } finally { + if (markConstructing) { + setObjectConstructionState(instance, false); + } + } + } + + function nativeClassForInstance(instance, classFallback, baseConstructor) { + var constructor = instance && instance.constructor; + if (constructor && constructor !== baseConstructor && + constructor !== classFallback) { + return constructor; + } + return classFallback || baseConstructor; + } + + // Gives extend()ed/TypeScript-native-subclass instances a `class`/ + // `superclass` identity that resolves to the ACTUAL (possibly further + // JS-subclassed) constructor rather than always reporting the class the + // extension was originally built against. + function installInstanceClassIdentity(target, classFallback, baseConstructor) { + if (!target || typeof Object.create !== 'function' || + typeof Object.setPrototypeOf !== 'function') { + return; + } + var parent = null; + try { + parent = Object.getPrototypeOf(target); + } catch (_) { + } + var identityPrototype = Object.create(parent || null); + try { + Object.defineProperty(identityPrototype, 'class', { + configurable: true, + enumerable: false, + writable: true, + value: function() { + return nativeClassForInstance(this, classFallback, baseConstructor); + } + }); + } catch (_) { + } + try { + Object.defineProperty(identityPrototype, 'superclass', { + configurable: true, + enumerable: false, + get: function() { + var constructor = nativeClassForInstance( + this, + classFallback, + baseConstructor + ); + if (!constructor) { + return undefined; + } + var superclass = constructor.superclass; + if (typeof superclass === 'function' && superclass.kind !== 'class') { + return superclass.call(constructor); + } + return superclass; + } + }); + } catch (_) { + } + try { + Object.setPrototypeOf(target, identityPrototype); + } catch (_) { } } @@ -622,8 +887,14 @@ function wrapNativeClass(nativeClass) { ); } } - if (args.length > 0) { - return rememberInstanceClass(constructNativeInstance(nativeClass, args, rememberInstanceClass)); + if (args.length > 0 || + shouldUseAllocInitConstructor(constructable, wrapper)) { + return rememberInstanceClass(constructNativeInstance( + nativeClass, + args, + rememberInstanceClass, + shouldUseAllocInitConstructor(constructable, wrapper) + )); } if (typeof nativeClass.new !== 'function') { throw new Error('Native class cannot be initialized'); @@ -650,29 +921,30 @@ function rememberInstanceClass(instance) { if (methods == null || typeof methods !== 'object') { throw new Error('extend() first parameter must be an object'); } - var extendOptions = options || {}; - if (typeof Symbol === 'function' && - Object.prototype.hasOwnProperty.call(methods, Symbol.iterator)) { - try { - extendOptions = Object.assign({}, extendOptions, { - __hasIterator: true - }); - } catch (_) { - extendOptions.__hasIterator = true; - } - } - var extendedNativeClass = api.__extendClass(nativeClass, methods, extendOptions); + var extensionMethods = nativeExtensionMethodsWithIndexedCollectionAliases(methods); + var extendOptions = + nativeExtensionOptionsWithIterator(options, extensionMethods); + var extendedNativeClass = api.__extendClass(nativeClass, extensionMethods, extendOptions); var extended = wrapNativeClass(extendedNativeClass); + try { + Object.defineProperty(extended, '__nativeApiUseAllocInitConstructor', { + configurable: false, + enumerable: false, + writable: false, + value: true + }); + } catch (_) { + } try { Object.setPrototypeOf(extended, wrapper || constructable); } catch (_) { } var extendedPrototype = Object.create(constructable.prototype || null); try { - Object.defineProperties(extendedPrototype, Object.getOwnPropertyDescriptors(methods)); + Object.defineProperties(extendedPrototype, Object.getOwnPropertyDescriptors(extensionMethods)); } catch (_) { - Object.keys(methods).forEach(function(key) { - extendedPrototype[key] = methods[key]; + Object.keys(extensionMethods).forEach(function(key) { + extendedPrototype[key] = extensionMethods[key]; }); } try { @@ -684,6 +956,7 @@ function rememberInstanceClass(instance) { }); } catch (_) { } + installInstanceClassIdentity(extendedPrototype, extended, constructable); extended.prototype = extendedPrototype; try { api.__rememberClassWrapper(extendedNativeClass, extended, extendedPrototype); @@ -1302,16 +1575,28 @@ function materializeTypeScriptNativeClass(constructor) { } var nativeBase = nativeClassLikeHandle(baseWrapper); - var nativeClass = api.__extendClass(nativeBase, constructor.prototype || {}, options); + var extensionMethods = + nativeExtensionMethodsWithIndexedCollectionAliases(constructor.prototype || {}); + options = nativeExtensionOptionsWithIterator(options, extensionMethods); + var nativeClass = api.__extendClass(nativeBase, extensionMethods, options); var wrapper = wrapNativeClass(nativeClass); state.wrapper = wrapper; + try { + Object.defineProperty(wrapper, '__nativeApiUseAllocInitConstructor', { + configurable: false, + enumerable: false, + writable: false, + value: true + }); + } catch (_) { + } try { Object.setPrototypeOf(constructor, wrapper); } catch (_) { } try { - api.__rememberClassWrapper(nativeClass, constructor, constructor.prototype || {}); + api.__rememberClassWrapper(nativeClass, constructor, extensionMethods); } catch (_) { } return wrapper; @@ -1429,6 +1714,8 @@ function installTypeScriptNativeClassSupport(constructor, base) { } catch (_) { } + installInstanceClassIdentity(constructor.prototype || {}, constructor, null); + ['alloc', 'new', 'class', 'superclass', 'extend'].forEach(function(name) { defineTypeScriptStaticForwarder(constructor, name, false, false); }); @@ -1800,8 +2087,9 @@ void InstallNativeApi(Runtime& runtime, const NativeApiConfig& config) { NativeApiWriteSmokeStage("engine:install-globals"); InstallNativeApiGlobalSymbols(runtime, globalName); } else { - NativeApiWriteSmokeStage("engine:install-aggregate-globals"); - InstallAggregateGlobals(runtime, api, "protocolNames"); + // RN doesn't install the aggregate global surface: unused, and building + // it eagerly costs launch time. + NativeApiWriteSmokeStage("engine:skip-globals"); } NativeApiWriteSmokeStage("engine:installed"); } diff --git a/NativeScript/ffi/objc/shared/bridge/InteropProfiler.h b/NativeScript/ffi/objc/shared/bridge/InteropProfiler.h new file mode 100644 index 000000000..b83a4a8e9 --- /dev/null +++ b/NativeScript/ffi/objc/shared/bridge/InteropProfiler.h @@ -0,0 +1,24 @@ +// Interop call profiling (NS_NS_HOST_PROFILE): global counters for the count +// and cumulative duration of every ffi dispatch, so host-lifecycle profiling +// can attribute each phase's cost to bridged native calls. C++17 inline +// variables at global scope — the engine TUs include the bridge sources inside +// an anonymous namespace, so these must live in their own header included at +// file scope to get one shared definition across TUs. +// +// gCallsAlways is a separate, always-on counterpart to gCalls: gCalls/gNs +// only increment when NS_NS_HOST_PROFILE is set (that flag also turns on +// verbose NSLog profiling elsewhere, which perturbs the very volume it's +// measuring). gCallsAlways is a single relaxed atomic increment with no +// timing, no branch on the profiling flag, and no allocation -- safe to +// leave live in every build so pop-perf gating has a trustworthy, +// non-self-perturbing interop-call count. +#pragma once + +#include +#include + +namespace nsInteropProfiler { +inline std::atomic gCalls{0}; +inline std::atomic gNs{0}; +inline std::atomic gCallsAlways{0}; +} // namespace nsInteropProfiler diff --git a/NativeScript/ffi/objc/shared/bridge/Invocation.mm b/NativeScript/ffi/objc/shared/bridge/Invocation.mm index af32e2d3e..682d5720a 100644 --- a/NativeScript/ffi/objc/shared/bridge/Invocation.mm +++ b/NativeScript/ffi/objc/shared/bridge/Invocation.mm @@ -1,3 +1,30 @@ +// Interop call profiling (NS_NS_HOST_PROFILE) — counters live in +// InteropProfiler.h (included at file scope by the engine TUs). +struct NativeScriptInteropCallTimer { + bool enabled; + CFAbsoluteTime start; + NativeScriptInteropCallTimer() { + // Always-on, unconditional count: a single relaxed atomic increment, + // no getenv branch, no clock read, no NSLog -- negligible cost, and + // deliberately NOT gated on NS_NS_HOST_PROFILE so this stays a clean, + // always-available signal for pop-perf gating. See InteropProfiler.h. + ::nsInteropProfiler::gCallsAlways.fetch_add(1, std::memory_order_relaxed); + static const bool profile = getenv("NS_NS_HOST_PROFILE") != nullptr; + enabled = profile; + if (enabled) { + start = CFAbsoluteTimeGetCurrent(); + } + } + ~NativeScriptInteropCallTimer() { + if (enabled) { + ::nsInteropProfiler::gCalls.fetch_add(1, std::memory_order_relaxed); + ::nsInteropProfiler::gNs.fetch_add( + (uint64_t)((CFAbsoluteTimeGetCurrent() - start) * 1e9), + std::memory_order_relaxed); + } + } +}; + bool isValidMetadataStringOffset(MDMetadataReader* metadata, MDSectionOffset offset) { if (metadata == nullptr || metadata->constantsOffset < metadata->stringsOffset) { @@ -347,6 +374,7 @@ NativeApiReturnStorage returnStorage( return; } } + NativeScriptInteropCallTimer nsInteropTimer; ffi_call(&signature->cif, FFI_FN(callable), returnStorage.data(), block ? values.data() : frame.values()); }); @@ -464,6 +492,7 @@ NativeApiReturnStorage returnStorage( prepared->preparedInvoker(prepared->function, frame.values(), returnStorage.data()); } else { + NativeScriptInteropCallTimer nsInteropTimer; ffi_call(&signature.cif, FFI_FN(prepared->function), returnStorage.data(), frame.values()); } @@ -521,6 +550,11 @@ bool signatureSupportedForEngineInvocation( SEL selector = nullptr; Class receiverClass = Nil; std::string selectorName; + // Set when this prepared invocation IS a metadata property's setter + // selector (one arg, matches member->setterSelectorName): lets a + // successful call cache the value into the UIAppearance proxy cache + // without re-deriving the property name from the selector. + std::string propertySetterName; NativeApiSignature signature; ObjCPreparedInvoker preparedInvoker = nullptr; void* engineInvoker = nullptr; // Engine-neutral GSD invoker (ObjCGsdInvoker) @@ -539,6 +573,17 @@ bool preparedObjCInvocationIsInit( return prepared.isInitMethod; } +void cachePreparedAppearanceProxySetterValue( + Runtime& runtime, const std::shared_ptr& bridge, + id receiver, const NativeApiPreparedObjCInvocation& prepared, + const Value* args, size_t count) { + if (prepared.propertySetterName.empty() || args == nullptr || count == 0) { + return; + } + cacheAppearanceProxyPropertyValue(runtime, bridge, receiver, + prepared.propertySetterName, args[0]); +} + bool isFastEngineObjectType(const NativeApiType& type) { switch (type.kind) { case metagen::mdTypeAnyObject: @@ -1472,6 +1517,12 @@ throw JSError( prepared->selector = selector; prepared->receiverClass = receiverIsClass ? lookupClass : Nil; prepared->selectorName = selectorName; + if (member != nullptr && member->property && !member->name.empty() && + !member->setterSelectorName.empty() && + selectorName == member->setterSelectorName && + selectorArgumentCount(selectorName) == 1) { + prepared->propertySetterName = member->name; + } prepared->signature = std::move(*signature); prepared->preparedInvoker = lookupObjCPreparedInvoker( dispatchIdForEngineSignature(prepared->signature, @@ -1487,6 +1538,34 @@ throw JSError( return prepared; } +bool isPreparedStaticAppearanceSelector( + const NativeApiPreparedObjCInvocation& prepared) { + return prepared.receiverClass != Nil && + prepared.selectorName.rfind("appearance", 0) == 0; +} + +Value tagPreparedStaticAppearanceSelectorResult( + Runtime& runtime, const std::shared_ptr& bridge, + id receiver, const NativeApiPreparedObjCInvocation& prepared, + Value result) { + return tagStaticAppearanceSelectorResult( + runtime, bridge, receiver, isPreparedStaticAppearanceSelector(prepared), + prepared.selectorName, std::move(result)); +} + +void tagPreparedStaticAppearanceNativeReturn( + Runtime& runtime, const std::shared_ptr& bridge, + id receiver, const NativeApiPreparedObjCInvocation& prepared, + const NativeApiType& returnType, void* returnData) { + if (!isPreparedStaticAppearanceSelector(prepared) || + !isObjectiveCObjectType(returnType) || returnData == nullptr) { + return; + } + tagStaticAppearanceNativeResult( + runtime, bridge, static_cast(receiver), + *static_cast(returnData)); +} + Value callPreparedObjCSelector( Runtime& runtime, const std::shared_ptr& bridge, id receiver, bool receiverIsClass, @@ -1503,11 +1582,17 @@ throw JSError(runtime, if (tryCallGeneratedEngineObjCSelector(runtime, bridge, receiver, prepared, args, count, dispatchSuperClass, &fastResult)) { - return fastResult; + cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver, + prepared, args, count); + return tagPreparedStaticAppearanceSelectorResult( + runtime, bridge, receiver, prepared, std::move(fastResult)); } if (tryCallFastEngineObjCSelector(runtime, bridge, receiver, prepared, args, count, dispatchSuperClass, &fastResult)) { - return fastResult; + cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver, + prepared, args, count); + return tagPreparedStaticAppearanceSelectorResult( + runtime, bridge, receiver, prepared, std::move(fastResult)); } NativeApiArgumentFrame frame(signature.argumentTypes.size()); @@ -1560,6 +1645,7 @@ NativeApiReturnStorage returnStorage( prepared.preparedInvoker(reinterpret_cast(objc_msgSend), values.data(), returnStorage.data()); } else { + NativeScriptInteropCallTimer nsInteropTimer; #if defined(__x86_64__) bool isStret = signature.returnType.ffiType->size > 16 && signature.returnType.ffiType->type == FFI_TYPE_STRUCT; @@ -1594,8 +1680,12 @@ NativeApiReturnStorage returnStorage( throw JSError( runtime, errorMessage != nullptr ? errorMessage : "Unknown NSError"); } - return convertNativeReturnValue(runtime, bridge, returnType, - returnStorage.data()); + cachePreparedAppearanceProxySetterValue(runtime, bridge, receiver, prepared, + args, count); + Value result = convertNativeReturnValue(runtime, bridge, returnType, + returnStorage.data()); + return tagPreparedStaticAppearanceSelectorResult( + runtime, bridge, receiver, prepared, std::move(result)); } Value callObjCSelector(Runtime& runtime, @@ -1617,7 +1707,22 @@ throw JSError(runtime, Class lookupClass = dispatchSuperClass != Nil ? dispatchSuperClass : receiverClass; Method method = receiverIsClass ? class_getClassMethod(lookupClass, selector) : class_getInstanceMethod(lookupClass, selector); + // A UIAppearance proxy is opaque to class_getInstanceMethod (it forwards + // selectors dynamically, so no Method exists for them) — allow through the + // exact property getter/setter selectors respondsToSelector: already + // vetted elsewhere, since -respondsToSelector: on the proxy itself can + // still return NO for a selector it will happily forward. + bool allowForwardedAppearancePropertySelector = false; + if (method == nullptr && !receiverIsClass && member != nullptr && + member->property && bridge != nullptr && + taggedAppearanceProxyClass(runtime, bridge, receiver) != Nil) { + allowForwardedAppearancePropertySelector = + (count == 0 && selectorName == member->selectorName) || + (count == 1 && !member->setterSelectorName.empty() && + selectorName == member->setterSelectorName); + } if (method == nullptr && + !allowForwardedAppearancePropertySelector && (dispatchSuperClass != Nil || ![receiver respondsToSelector:selector])) { throw JSError(runtime, "Objective-C selector is not available: " + @@ -1666,12 +1771,16 @@ throw JSError( if (tryCallGeneratedEngineObjCSelector(runtime, bridge, receiver, engineInvocation, args, count, dispatchSuperClass, &fastResult)) { - return fastResult; + return tagStaticAppearanceSelectorResult( + runtime, bridge, receiver, receiverIsClass, selectorName, + std::move(fastResult)); } if (tryCallFastEngineObjCSelector(runtime, bridge, receiver, engineInvocation, args, count, dispatchSuperClass, &fastResult)) { - return fastResult; + return tagStaticAppearanceSelectorResult( + runtime, bridge, receiver, receiverIsClass, selectorName, + std::move(fastResult)); } NativeApiArgumentFrame frame(signature->argumentTypes.size()); @@ -1728,6 +1837,7 @@ NativeApiReturnStorage returnStorage( preparedInvoker(reinterpret_cast(objc_msgSend), values.data(), returnStorage.data()); } else { + NativeScriptInteropCallTimer nsInteropTimer; #if defined(__x86_64__) bool isStret = signature->returnType.ffiType->size > 16 && signature->returnType.ffiType->type == FFI_TYPE_STRUCT; @@ -1759,6 +1869,9 @@ NativeApiReturnStorage returnStorage( throw JSError( runtime, errorMessage != nullptr ? errorMessage : "Unknown NSError"); } - return convertNativeReturnValue(runtime, bridge, returnType, - returnStorage.data()); + Value result = convertNativeReturnValue(runtime, bridge, returnType, + returnStorage.data()); + return tagStaticAppearanceSelectorResult( + runtime, bridge, receiver, receiverIsClass, selectorName, + std::move(result)); } diff --git a/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm b/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm index 5baecf609..b371acbb6 100644 --- a/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm +++ b/NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm @@ -509,6 +509,21 @@ inline uintptr_t normalizeRuntimePointer(uintptr_t pointer) { #endif } +// One bridge is shared by every Runtime that touches the same process (a +// worklet spins up an additional Runtime on its own thread). Expandos are +// per-runtime: a Value created in one Runtime must never be handed back out +// of a different one, so every expando read/write is keyed on the owning +// runtime's identity, not just the native pointer. +uintptr_t runtimeObjectExpandoKey(Runtime& runtime) { +#if defined(TARGET_ENGINE_V8) || defined(TARGET_ENGINE_JSC) || \ + defined(TARGET_ENGINE_QUICKJS) + return normalizeRuntimePointer( + reinterpret_cast(runtime.state().get())); +#else + return normalizeRuntimePointer(reinterpret_cast(&runtime)); +#endif +} + class NativeApiBridge { struct NativeApiRoundTripValue { std::shared_ptr value; @@ -533,8 +548,10 @@ explicit NativeApiBridge(const NativeApiConfig& config) runtimeCallbackInvoker_(config.runtimeCallbackInvoker), jsThreadCallbackInvoker_(config.jsThreadCallbackInvoker), jsThreadAsyncCallbackInvoker_(config.jsThreadAsyncCallbackInvoker), + callbackInvocationAllowed_(config.callbackInvocationAllowed), invokeCallbacksOnNativeCallerThread_( - config.invokeCallbacksOnNativeCallerThread) { + config.invokeCallbacksOnNativeCallerThread), + indexRuntimePointers_(config.indexRuntimePointers) { selfDl_ = dlopen(nullptr, RTLD_NOW); buildSymbolIndexes(); } @@ -642,6 +659,16 @@ void rememberRoundTripValue(Runtime& runtime, const void* native, #endif } + // Convenience for preservedNativeApiInitializerSelfReturn: remembers an id + // (rather than an arbitrary native pointer) with the class-derived + // validation key, the same key an ordinary object wrapping would use. + void rememberNativeObjectRoundTripValue(Runtime& runtime, id object, + const Value& value, + bool stringLikeNative = false) { + rememberRoundTripValue(runtime, object, value, stringLikeNative, + nativeObjectClassKey(object)); + } + void rememberScopedRoundTripValue(Runtime& runtime, const void* native, const Value& value, bool stringLikeNative = false, @@ -929,24 +956,38 @@ Value findClassPrototype(Runtime& runtime, Class cls) const { return Value(runtime, *it->second); } + // Expandos are keyed native-pointer -> property -> owning-runtime, all + // guarded by objectExpandosMutex_: worklet runtimes run on their own + // thread but share this bridge, and a host-object dtor releasing its + // expando owner count can run on either thread relative to a get/set. void setObjectExpando(Runtime& runtime, const void* native, const std::string& property, const Value& value) { if (native == nullptr || property.empty()) { return; } - objectExpandos_[normalizeRuntimePointer(reinterpret_cast(native))] - [property] = std::make_shared(runtime, value); - objectExpandosGeneration_.fetch_add(1, std::memory_order_release); + const uintptr_t key = + normalizeRuntimePointer(reinterpret_cast(native)); + const uintptr_t runtimeKey = runtimeObjectExpandoKey(runtime); + { + std::lock_guard lock(objectExpandosMutex_); + objectExpandos_[key][property][runtimeKey] = + std::make_shared(runtime, value); + objectExpandosGeneration_.fetch_add(1, std::memory_order_release); + } } void retainObjectExpandoOwner(const void* native) { if (native == nullptr) { return; } + std::lock_guard lock(objectExpandosMutex_); objectExpandoOwnerCounts_[ normalizeRuntimePointer(reinterpret_cast(native))] += 1; } + // preserveExpandos keeps the stored values around after the last owner + // releases (used when a wrapper is being replaced/detached but the + // underlying native receiver's expando state must survive the swap). void releaseObjectExpandoOwner(const void* native, bool preserveExpandos = false) { if (native == nullptr) { @@ -954,15 +995,20 @@ void releaseObjectExpandoOwner(const void* native, } uintptr_t key = normalizeRuntimePointer(reinterpret_cast(native)); - auto ownerIt = objectExpandoOwnerCounts_.find(key); - if (ownerIt != objectExpandoOwnerCounts_.end()) { - if (ownerIt->second > 1) { - ownerIt->second -= 1; - return; + bool shouldForget = false; + { + std::lock_guard lock(objectExpandosMutex_); + auto ownerIt = objectExpandoOwnerCounts_.find(key); + if (ownerIt != objectExpandoOwnerCounts_.end()) { + if (ownerIt->second > 1) { + ownerIt->second -= 1; + return; + } + objectExpandoOwnerCounts_.erase(ownerIt); } - objectExpandoOwnerCounts_.erase(ownerIt); + shouldForget = !preserveExpandos; } - if (!preserveExpandos) { + if (shouldForget) { forgetObjectExpandos(native); } } @@ -975,6 +1021,7 @@ Value findObjectExpando(Runtime& runtime, const void* native, struct ObjectExpandoCacheEntry { const NativeApiBridge* bridge = nullptr; uintptr_t key = 0; + uintptr_t runtimeKey = 0; uint64_t generation = 0; std::string property; std::weak_ptr value; @@ -985,11 +1032,13 @@ Value findObjectExpando(Runtime& runtime, const void* native, const uintptr_t key = normalizeRuntimePointer(reinterpret_cast(native)); + const uintptr_t runtimeKey = runtimeObjectExpandoKey(runtime); const uint64_t generation = objectExpandosGeneration_.load(std::memory_order_acquire); for (auto& entry : cache) { if (entry.bridge == this && entry.key == key && - entry.generation == generation && entry.property == property) { + entry.runtimeKey == runtimeKey && entry.generation == generation && + entry.property == property) { if (entry.miss) { return Value::undefined(); } @@ -1000,32 +1049,44 @@ Value findObjectExpando(Runtime& runtime, const void* native, } } - auto objectIt = objectExpandos_.find(key); + std::shared_ptr storedValue; const size_t slot = nextSlot++ & 7; - if (objectIt == objectExpandos_.end()) { - cache[slot] = - ObjectExpandoCacheEntry{this, key, generation, property, {}, true}; - return Value::undefined(); - } - auto propertyIt = objectIt->second.find(property); - if (propertyIt == objectIt->second.end() || propertyIt->second == nullptr) { - cache[slot] = - ObjectExpandoCacheEntry{this, key, generation, property, {}, true}; - return Value::undefined(); + { + std::lock_guard lock(objectExpandosMutex_); + auto objectIt = objectExpandos_.find(key); + if (objectIt != objectExpandos_.end()) { + auto propertyIt = objectIt->second.find(property); + if (propertyIt != objectIt->second.end()) { + auto runtimeIt = propertyIt->second.find(runtimeKey); + if (runtimeIt != propertyIt->second.end() && + runtimeIt->second != nullptr) { + storedValue = runtimeIt->second; + } + } + } + if (storedValue == nullptr) { + cache[slot] = ObjectExpandoCacheEntry{ + this, key, runtimeKey, generation, property, {}, true}; + return Value::undefined(); + } + cache[slot] = ObjectExpandoCacheEntry{ + this, key, runtimeKey, generation, property, storedValue, false}; } - cache[slot] = ObjectExpandoCacheEntry{ - this, key, generation, property, propertyIt->second, false}; - return Value(runtime, *propertyIt->second); + return Value(runtime, *storedValue); } + // Erases every runtime's stored values for this native (codex semantics): + // the native object itself is gone, so no runtime should keep seeing it. void forgetObjectExpandos(const void* native) { if (native == nullptr) { return; } auto key = normalizeRuntimePointer(reinterpret_cast(native)); - objectExpandos_.erase( - key); - objectExpandosGeneration_.fetch_add(1, std::memory_order_release); + { + std::lock_guard lock(objectExpandosMutex_); + objectExpandos_.erase(key); + objectExpandosGeneration_.fetch_add(1, std::memory_order_release); + } } // Per-class cache of resolved metadata property-getter members. Lets the @@ -1187,6 +1248,26 @@ void forgetPointerValue(const void* native) { jsThreadAsyncCallbackInvoker() const { return jsThreadAsyncCallbackInvoker_; } + // Teardown-safety gate: RN sets this so callbacks can be refused once the + // host is tearing down/reloading, without every call site needing to know + // why. Both a C++ try and an @try wrap the call — they catch different + // exception families (std::exception-derived vs. NSException), and either + // one escaping here would otherwise cross into caller frames that aren't + // set up to catch it. + bool callbackInvocationAllowed() const noexcept { + if (!callbackInvocationAllowed_) { + return true; + } + @try { + try { + return callbackInvocationAllowed_(); + } catch (...) { + return false; + } + } @catch (...) { + return false; + } + } bool invokeCallbacksOnNativeCallerThread() const { return invokeCallbacksOnNativeCallerThread_; } @@ -1465,10 +1546,12 @@ void addSymbol(NativeApiSymbolKind kind, MDSectionOffset offset, if (kind == NativeApiSymbolKind::Class) { classSymbolsByOffset_[symbol.offset] = symbol; classSymbolsByRuntimeName_[symbol.runtimeName] = symbol; - Class cls = objc_lookUpClass(symbol.runtimeName.c_str()); - if (cls != Nil) { - classSymbolsByRuntimePointer_[normalizeRuntimePointer( - reinterpret_cast(cls))] = symbol; + if (indexRuntimePointers_) { + Class cls = objc_lookUpClass(symbol.runtimeName.c_str()); + if (cls != Nil) { + classSymbolsByRuntimePointer_[normalizeRuntimePointer( + reinterpret_cast(cls))] = symbol; + } } } else if (kind == NativeApiSymbolKind::Protocol) { protocolSymbolsByOffset_[symbol.offset] = symbol; @@ -1478,10 +1561,12 @@ void addSymbol(NativeApiSymbolKind kind, MDSectionOffset offset, return; } protocolSymbolsByRuntimeName_[runtimeName] = symbol; - Protocol* runtimeProtocol = lookupProtocolByNativeName(runtimeName); - if (runtimeProtocol != nullptr) { - protocolSymbolsByRuntimePointer_[normalizeRuntimePointer( - reinterpret_cast(runtimeProtocol))] = symbol; + if (indexRuntimePointers_) { + Protocol* runtimeProtocol = lookupProtocolByNativeName(runtimeName); + if (runtimeProtocol != nullptr) { + protocolSymbolsByRuntimePointer_[normalizeRuntimePointer( + reinterpret_cast(runtimeProtocol))] = symbol; + } } }; if (symbol.name.size() > 9 && @@ -1500,13 +1585,15 @@ void addSymbol(NativeApiSymbolKind kind, MDSectionOffset offset, symbol.name.substr(0, digitsStart - protocolSuffixLength)); } } - Protocol* protocol = lookupProtocolByNativeName(symbol.runtimeName); - if (protocol == nullptr && symbol.runtimeName != symbol.name) { - protocol = lookupProtocolByNativeName(symbol.name); - } - if (protocol != nullptr) { - protocolSymbolsByRuntimePointer_[normalizeRuntimePointer( - reinterpret_cast(protocol))] = symbol; + if (indexRuntimePointers_) { + Protocol* protocol = lookupProtocolByNativeName(symbol.runtimeName); + if (protocol == nullptr && symbol.runtimeName != symbol.name) { + protocol = lookupProtocolByNativeName(symbol.name); + } + if (protocol != nullptr) { + protocolSymbolsByRuntimePointer_[normalizeRuntimePointer( + reinterpret_cast(protocol))] = symbol; + } } } else if (kind == NativeApiSymbolKind::Struct) { structSymbolsByOffset_[symbol.offset] = symbol; @@ -2128,8 +2215,12 @@ static void appendSurfaceMember( std::unordered_map> classValues_; std::unordered_map> classPrototypes_; std::unordered_map> pointerValues_; - std::unordered_map>> + mutable std::mutex objectExpandosMutex_; + std::unordered_map< + uintptr_t, + std::unordered_map< + std::string, + std::unordered_map>>> objectExpandos_; std::unordered_map objectExpandoOwnerCounts_; std::atomic objectExpandosGeneration_{1}; @@ -2151,7 +2242,9 @@ static void appendSurfaceMember( std::function)> runtimeCallbackInvoker_; std::function)> jsThreadCallbackInvoker_; std::function)> jsThreadAsyncCallbackInvoker_; + std::function callbackInvocationAllowed_; bool invokeCallbacksOnNativeCallerThread_ = false; + bool indexRuntimePointers_ = true; mutable std::unordered_map> membersByClassOffset_; mutable std::unordered_map> @@ -2210,10 +2303,26 @@ bool nativeObjectReturnMayCoerceToString(const NativeApiType& type) { type.kind == metagen::mdTypeNSStringObject; } -bool nativeObjectIsStringLike(id object) { +// Guards against treating a misread register value as an object pointer: +// low addresses can't be valid ObjC objects, but a register holding e.g. an +// unboxed integer or a non-object primitive read as `id` can land there. +// Without this guard, dereferencing it (object_getClass, isKindOfClass:, +// etc. below) can crash on garbage. Do not remove — this is what stops +// crashes on AnyObject-typed returns from selectors whose actual return +// isn't an object. +bool nativeObjectPointerMayBeObject(id object) { if (object == nil) { return false; } + + const uintptr_t raw = reinterpret_cast(object); + return raw > 0x1000; +} + +bool nativeObjectIsStringLike(id object) { + if (!nativeObjectPointerMayBeObject(object)) { + return false; + } Class cls = object_getClass(object); struct StringLikeClassCacheEntry { Class cls = Nil; @@ -2235,6 +2344,10 @@ bool nativeObjectIsStringLike(id object) { Value findCachedNativeObjectReturn(Runtime& runtime, const std::shared_ptr& bridge, const NativeApiType& type, id object) { + if (!nativeObjectPointerMayBeObject(object)) { + return Value::undefined(); + } + bool roundTripStringLike = false; const bool stringReturnCandidate = nativeObjectReturnMayCoerceToString(type); // AnyObject/NSString returns intentionally coerce string-like native objects @@ -2358,7 +2471,8 @@ Function CreateNativeApiBoundSelectorGroupFunction( Value makeNativeObjectValue(Runtime& runtime, const std::shared_ptr& bridge, - id object, bool ownsObject); + id object, bool ownsObject, + Class superDispatchClass = Nil); Value makeNativeClassValue(Runtime& runtime, const std::shared_ptr& bridge, diff --git a/NativeScript/ffi/objc/shared/bridge/SelectorGroupCall.h b/NativeScript/ffi/objc/shared/bridge/SelectorGroupCall.h index ba7f3887b..907aef1cb 100644 --- a/NativeScript/ffi/objc/shared/bridge/SelectorGroupCall.h +++ b/NativeScript/ffi/objc/shared/bridge/SelectorGroupCall.h @@ -10,6 +10,11 @@ struct NativeApiResolvedSelectorGroupCall { Class dispatchClass = Nil; bool hasImmediateResult = false; Value immediateResult; + // False when the prepared invocation is a `[SomeClass appearance...]` + // static selector: the GSD fast path bypasses tagStaticAppearance*, so it + // must be excluded from GSD eligibility to keep proxy tagging/caching + // working. + bool gsdAllowed = true; }; template object() - : resolveReceiver(); + : nil; + if (result.receiver == nil) { + // Either this call is unbound (the common case -- `resolveReceiver()` + // resolves the live `thisValue`), OR it IS bound but the bound + // receiver's `NativeApiObjectHostObject` wrapper has already been torn + // down: the cached selector-group function itself survives as a + // native-object expando (keyed by the native pointer, see Object.mm's + // `bridge_->setObjectExpando(..., methodFunction)`), which outlives the + // specific wrapper instance it was bound to when this runtime later + // mints a FRESH wrapper for the same native pointer on another + // crossing. Re-resolve from the actual call-site receiver in both + // cases -- for a bound call this is exactly the live object the method + // is being invoked on right now, so it is always correct. + result.receiver = resolveReceiver(); + } } if (result.receiver == nil) { throw JSError(runtime, @@ -45,6 +64,15 @@ inline NativeApiResolvedSelectorGroupCall resolveNativeApiSelectorGroupCall( const bool propertyGetterCall = entry.hasMember && entry.member.property && argumentCount == 0; + if (propertyGetterCall) { + Value appearanceExpando = cachedAppearanceProxyPropertyValue( + runtime, data.bridge, result.receiver, entry.member.name); + if (!appearanceExpando.isUndefined()) { + result.immediateResult = std::move(appearanceExpando); + result.hasImmediateResult = true; + return result; + } + } const std::string* selectorNamePtr = &entry.selectorName; const NativeApiMember* selectedMember = entry.hasMember ? &entry.member : nullptr; @@ -112,6 +140,7 @@ inline NativeApiResolvedSelectorGroupCall resolveNativeApiSelectorGroupCall( } } result.prepared = prepared.get(); + result.gsdAllowed = !isPreparedStaticAppearanceSelector(*prepared); if constexpr (PrepareInitializer) { if (!data.receiverIsClass && prepared->isInitMethod) { diff --git a/NativeScript/ffi/objc/shared/bridge/TypeConv.mm b/NativeScript/ffi/objc/shared/bridge/TypeConv.mm index 670b38bfe..b71f8ad39 100644 --- a/NativeScript/ffi/objc/shared/bridge/TypeConv.mm +++ b/NativeScript/ffi/objc/shared/bridge/TypeConv.mm @@ -476,6 +476,68 @@ bool readPointerLikeValue(Runtime& runtime, const Value& value, void** pointer) return readNativePointerProperty(runtime, object, pointer); } +// interop.set/getAssociatedObject's target parameter accepts either a live +// wrapped native object/pointer, or (for cases the engine can't hold a +// reference to, e.g. round-tripping a raw address from logs/debugging) the +// decimal text of a pointer value. +id nativeAssociatedObjectTargetFromValue(Runtime& runtime, const Value& value) { + if (value.isNull() || value.isUndefined()) { + return nil; + } + + if (value.isString()) { + uintptr_t address = 0; + if (!parseIntegerTextToUintptr(value.asString(runtime).utf8(runtime), &address)) { + throw JSError(runtime, "Associated object target expects a native object or object pointer."); + } + return static_cast(reinterpret_cast(address)); + } + + if (!value.isObject()) { + throw JSError(runtime, "Associated object target expects a native object or object pointer."); + } + + void* pointer = nullptr; + if (readPointerLikeValue(runtime, value, &pointer)) { + return static_cast(pointer); + } + + throw JSError(runtime, "Associated object target expects a native object or object pointer."); +} + +objc_AssociationPolicy associatedObjectPolicyFromValue(Runtime& runtime, const Value& value) { + if (value.isUndefined() || value.isNull()) { + return OBJC_ASSOCIATION_RETAIN_NONATOMIC; + } + + if (value.isNumber()) { + return static_cast(static_cast(value.getNumber())); + } + + if (!value.isString()) { + throw JSError(runtime, "Associated object policy expects a string or numeric objc_AssociationPolicy."); + } + + std::string policy = value.asString(runtime).utf8(runtime); + if (policy == "assign") { + return OBJC_ASSOCIATION_ASSIGN; + } + if (policy == "retain") { + return OBJC_ASSOCIATION_RETAIN; + } + if (policy == "retainNonatomic" || policy == "strong" || policy == "strongNonatomic") { + return OBJC_ASSOCIATION_RETAIN_NONATOMIC; + } + if (policy == "copy") { + return OBJC_ASSOCIATION_COPY; + } + if (policy == "copyNonatomic") { + return OBJC_ASSOCIATION_COPY_NONATOMIC; + } + + throw JSError(runtime, "Unknown associated object policy."); +} + template void writeNumericArgument(Runtime& runtime, const Value& value, void* target, const char* typeName) { @@ -1042,6 +1104,9 @@ throw JSError(runtime, "This native return type is not supported by " if (object == nil) { return Value::null(); } + if (!nativeObjectPointerMayBeObject(object)) { + return Value::undefined(); + } Value roundTrip = findCachedNativeObjectReturn(runtime, bridge, type, object); if (!roundTrip.isUndefined()) { if (type.returnOwned) { @@ -1075,6 +1140,37 @@ throw JSError(runtime, "This native return type is not supported by " } return result; } + if (object_isClass(object)) { + Class cls = static_cast(object); + Value cachedClass = bridge->findClassValue(runtime, cls); + if (!cachedClass.isUndefined()) { + if (type.returnOwned) { + [object release]; + } + return cachedClass; + } + + const char* className = class_getName(cls); + NativeApiSymbol symbol{ + .kind = NativeApiSymbolKind::Class, + .offset = MD_SECTION_OFFSET_NULL, + .name = className != nullptr ? className : "", + .runtimeName = className != nullptr ? className : "", + }; + if (const NativeApiSymbol* found = + bridge->findClassForRuntimePointer(cls)) { + symbol = *found; + } else if (const NativeApiSymbol* found = + bridge->findClassForRuntimeClass(cls)) { + symbol = *found; + } + Value classValue = + makeNativeClassValue(runtime, bridge, std::move(symbol)); + if (type.returnOwned) { + [object release]; + } + return classValue; + } if (const NativeApiSymbol* classSymbol = bridge->findClassForRuntimePointer((void*)object)) { return makeNativeClassValue(runtime, bridge, *classSymbol); } @@ -1745,8 +1841,20 @@ Object createInteropObject(Runtime& runtime, const std::shared_ptr Value { + if (count < 3) { + throw JSError(runtime, + "interop.setAssociatedObject expects target, key, and value."); + } + id target = nativeAssociatedObjectTargetFromValue(runtime, args[0]); + if (target == nil || !args[1].isString()) { + throw JSError(runtime, + "interop.setAssociatedObject expects target, key, and value."); + } + + std::string key = args[1].asString(runtime).utf8(runtime); + NativeApiArgumentFrame frame(1); + id value = nil; + if (!args[2].isNull() && !args[2].isUndefined()) { + value = objectFromEngineValue(runtime, bridge, args[2], frame, false); + } + objc_AssociationPolicy policy = + count > 3 ? associatedObjectPolicyFromValue(runtime, args[3]) + : OBJC_ASSOCIATION_RETAIN_NONATOMIC; + objc_setAssociatedObject(target, sel_registerName(key.c_str()), value, policy); + return Value::undefined(); + })); + + interop.setProperty( + runtime, "getAssociatedObject", + Function::createFromHostFunction( + runtime, PropNameID::forAscii(runtime, "getAssociatedObject"), 2, + [bridge](Runtime& runtime, const Value&, const Value* args, size_t count) -> Value { + if (count < 2 || !args[1].isString()) { + throw JSError(runtime, + "interop.getAssociatedObject expects target and key."); + } + id target = nativeAssociatedObjectTargetFromValue(runtime, args[0]); + if (target == nil) { + return Value::null(); + } + + std::string key = args[1].asString(runtime).utf8(runtime); + id associated = objc_getAssociatedObject(target, sel_registerName(key.c_str())); + if (associated == nil) { + return Value::null(); + } + + NativeApiType type = nativeObjectReturnTypeForClass(object_getClass(associated)); + return convertNativeReturnValue(runtime, bridge, type, &associated); + })); + interop.setProperty( runtime, "stringFromCString", Function::createFromHostFunction( diff --git a/NativeScript/ffi/objc/shared/bridge/host_objects/Appearance.mm b/NativeScript/ffi/objc/shared/bridge/host_objects/Appearance.mm new file mode 100644 index 000000000..58b855ceb --- /dev/null +++ b/NativeScript/ffi/objc/shared/bridge/host_objects/Appearance.mm @@ -0,0 +1,294 @@ +// UIAppearance proxy primitives. +// +// `[SomeView appearance]` (and the whenContainedIn:/appearanceWhenContainedIn: +// variants) hands back an opaque `_UIAppearance` proxy, not a real instance of +// the class — UIKit forwards whatever selectors it recognizes to an internal +// invocation-recording store instead of actually executing them. There is no +// public, introspectable way to ask one of these proxies "what class are you +// a proxy for" other than parsing its `-description`, which UIKit formats as +// ``. Everything below exists to (a) recover +// that class from the description once, tag it onto the wrapped object as an +// expando so we don't have to re-parse on every access, and (b) cache +// get/set values in a class-keyed expando store (keyed on the customizable +// class, not the proxy instance — UIAppearance state is effectively global +// per class/containment-chain, not per proxy object) so repeated reads see +// the value a set() just wrote instead of round-tripping back into UIKit's +// opaque recording machinery. Setters ALSO cache: an appearance proxy setter +// doesn't reliably support read-your-write, so we do it ourselves. + +// Forward declaration: runtimeWritablePropertySetter is defined later in +// Object.mm (this file is included before it), but is needed here by +// makeAppearanceProxyPropertySetter's non-metadata-setter fallback. +std::optional runtimeWritablePropertySetter( + id object, const std::string& property); + +constexpr const char* kNativeApiAppearanceClassNameExpando = + "__nativeApiAppearanceClassName"; + +// Parses UIKit's `` description format. +Class appearanceProxyCustomizableClassFromExactDescription(id object) { +#if TARGET_OS_IPHONE + if (object == nil) { + return Nil; + } + + NSString* description = [object description]; + NSString* prefix = @""]) { + return Nil; + } + + NSRange classNameRange = + NSMakeRange(prefix.length, description.length - prefix.length - 1); + NSString* className = [description substringWithRange:classNameRange]; + return NSClassFromString(className); +#else + return Nil; +#endif +} + +// The customizable class an appearance proxy wraps, preferring the tagged +// expando (set once by tagStaticAppearanceNativeResult) over re-parsing the +// description on every access. +Class taggedAppearanceProxyClass( + Runtime& runtime, const std::shared_ptr& bridge, + id object) { + if (object == nil || bridge == nullptr) { + return Nil; + } + + Value classNameValue = bridge->findObjectExpando( + runtime, object, kNativeApiAppearanceClassNameExpando); + if (!classNameValue.isString()) { + return appearanceProxyCustomizableClassFromExactDescription(object); + } + + std::string className = + classNameValue.asString(runtime).utf8(runtime); + return objc_lookUpClass(className.c_str()); +} + +std::string appearanceProxyExpandoPropertyKey( + const std::string& property) { + return "__nativeApiAppearance:" + property; +} + +// Cached class-keyed (not proxy-instance-keyed — see file header) UIAppearance +// property value. +Value cachedAppearanceProxyPropertyValue( + Runtime& runtime, const std::shared_ptr& bridge, + id object, const std::string& property) { + if (Class appearanceClass = + taggedAppearanceProxyClass(runtime, bridge, object)) { + return bridge->findObjectExpando( + runtime, appearanceClass, appearanceProxyExpandoPropertyKey(property)); + } + return Value::undefined(); +} + +void cacheAppearanceProxyPropertyValue( + Runtime& runtime, const std::shared_ptr& bridge, + id object, const std::string& property, const Value& value) { + if (Class appearanceClass = + taggedAppearanceProxyClass(runtime, bridge, object)) { + bridge->setObjectExpando(runtime, appearanceClass, + appearanceProxyExpandoPropertyKey(property), + value); + } +} + +// Picks the more capable of two candidate members exposing the same property +// name (prefers writable over readonly, a member with a known setter +// selector, a member with resolved signature metadata). +const NativeApiMember* betterAppearanceProxyAccessorMember( + const NativeApiMember* current, const NativeApiMember& candidate) { + if (current == nullptr) { + return &candidate; + } + if (current->readonly != candidate.readonly) { + return candidate.readonly ? current : &candidate; + } + if (current->setterSelectorName.empty() && + !candidate.setterSelectorName.empty()) { + return &candidate; + } + if (current->signatureOffset == MD_SECTION_OFFSET_NULL && + candidate.signatureOffset != MD_SECTION_OFFSET_NULL) { + return &candidate; + } + return current; +} + +const NativeApiMember* selectAppearanceProxyPropertyMember( + const std::vector& members, const std::string& property) { + const NativeApiMember* selected = nullptr; + for (const auto& member : members) { + if (!member.property || member.name != property) { + continue; + } + bool memberIsStatic = (member.flags & metagen::mdMemberStatic) != 0; + if (memberIsStatic) { + continue; + } + selected = betterAppearanceProxyAccessorMember(selected, member); + } + return selected; +} + +// True for a `[SomeClass appearance...]` static-selector call — the class +// receiver + selector-name convention UIKit uses for all of the appearance +// proxy factory methods. +bool isStaticAppearanceSelector(bool receiverIsClass, + const std::string& selectorName) { + return receiverIsClass && selectorName.rfind("appearance", 0) == 0; +} + +// Recovers the customizable class from an appearance proxy's description and +// tags it onto the proxy as an expando (so future accesses don't need to +// re-parse the description). +Class tagStaticAppearanceNativeResult( + Runtime& runtime, const std::shared_ptr& bridge, + Class appearanceClass, id native) { + if (bridge == nullptr || appearanceClass == Nil || native == nil) { + return Nil; + } + Class customizableClass = + appearanceProxyCustomizableClassFromExactDescription(native); + if (customizableClass == Nil) { + return Nil; + } + const char* className = class_getName(customizableClass); + if (className == nullptr || className[0] == '\0') { + return Nil; + } + bridge->setObjectExpando(runtime, native, kNativeApiAppearanceClassNameExpando, + makeString(runtime, className)); + return customizableClass; +} + +std::shared_ptr retainAppearanceProxyForAccessor(id native) { + id retained = [native retain]; + return std::shared_ptr(static_cast(retained), [](void* value) { + [(id)value release]; + }); +} + +bool shouldInstallAppearanceProxyAccessor(const NativeApiMember& member) { + if (!member.property || member.name.empty()) { + return false; + } + bool memberIsStatic = (member.flags & metagen::mdMemberStatic) != 0; + if (memberIsStatic) { + return false; + } + return member.name != "superclass" && member.name != "class" && + member.name != "constructor" && member.name != "debugDescription" && + member.name != "className" && member.name != "description"; +} + +Function makeAppearanceProxyPropertyGetter( + Runtime& runtime, std::shared_ptr bridge, id native, + std::shared_ptr retainedNative, std::string property) { + return Function::createFromHostFunction( + runtime, PropNameID::forAscii(runtime, property.c_str()), 0, + [bridge = std::move(bridge), native, retainedNative = std::move(retainedNative), + property = std::move(property)](Runtime& runtime, const Value&, + const Value*, size_t) -> Value { + return cachedAppearanceProxyPropertyValue(runtime, bridge, native, + property); + }); +} + +Function makeAppearanceProxyPropertySetter( + Runtime& runtime, std::shared_ptr bridge, id native, + std::shared_ptr retainedNative, NativeApiMember member) { + std::string functionName = member.setterSelectorName.empty() + ? member.name + : member.setterSelectorName; + return Function::createFromHostFunction( + runtime, PropNameID::forAscii(runtime, functionName.c_str()), 1, + [bridge = std::move(bridge), native, retainedNative = std::move(retainedNative), + member = std::move(member)](Runtime& runtime, const Value&, const Value* args, + size_t count) -> Value { + if (count < 1) { + throw JSError(runtime, + "UIAppearance property setter expects a value."); + } + Value setterArgs[] = {Value(runtime, args[0])}; + if (!member.setterSelectorName.empty()) { + NativeApiMember setterMember = member; + setterMember.selectorName = member.setterSelectorName; + setterMember.signatureOffset = member.setterSignatureOffset; + callObjCSelector(runtime, bridge, native, false, + setterMember.selectorName, &setterMember, + setterArgs, 1); + } else if (auto setterSelectorName = + runtimeWritablePropertySetter(native, member.name)) { + callObjCSelector(runtime, bridge, native, false, *setterSelectorName, + nullptr, setterArgs, 1); + } else { + throw JSError(runtime, + "UIAppearance property setter is unavailable."); + } + cacheAppearanceProxyPropertyValue(runtime, bridge, native, member.name, + args[0]); + return Value::undefined(); + }); +} + +// Installs get/set accessor descriptors for every writable metadata property +// of `customizableClass` onto `resultObject` (the JS wrapper for the +// appearance proxy) — this is what makes `View.appearance().tintColor = ...` +// resolve as a real property assignment instead of requiring `.invoke(...)`. +void installAppearanceProxyPropertyAccessors( + Runtime& runtime, const std::shared_ptr& bridge, + Class customizableClass, id native, Object& resultObject) { + if (bridge == nullptr || customizableClass == Nil) { + return; + } + const NativeApiSymbol* symbol = + bridge->findClassForRuntimeClass(customizableClass); + if (symbol == nullptr) { + return; + } + + Object objectConstructor = + runtime.global().getPropertyAsObject(runtime, "Object"); + Function defineProperty = + objectConstructor.getPropertyAsFunction(runtime, "defineProperty"); + std::shared_ptr retainedNative = + retainAppearanceProxyForAccessor(native); + const auto& members = bridge->membersForClass(*symbol); + std::unordered_map accessors; + for (const auto& member : members) { + if (!shouldInstallAppearanceProxyAccessor(member)) { + continue; + } + accessors[member.name] = + betterAppearanceProxyAccessorMember(accessors[member.name], member); + } + + for (const auto& accessor : accessors) { + const NativeApiMember& member = *accessor.second; + + try { + Object descriptor(runtime); + descriptor.setProperty(runtime, "configurable", true); + descriptor.setProperty(runtime, "enumerable", false); + descriptor.setProperty( + runtime, "get", + makeAppearanceProxyPropertyGetter(runtime, bridge, native, + retainedNative, member.name)); + if (!member.readonly) { + descriptor.setProperty( + runtime, "set", + makeAppearanceProxyPropertySetter(runtime, bridge, native, + retainedNative, member)); + } + defineProperty.call(runtime, resultObject, makeString(runtime, member.name), + descriptor); + } catch (const std::exception&) { + } + } +} diff --git a/NativeScript/ffi/objc/shared/bridge/host_objects/Class.mm b/NativeScript/ffi/objc/shared/bridge/host_objects/Class.mm index e74c4032b..500bd901c 100644 --- a/NativeScript/ffi/objc/shared/bridge/host_objects/Class.mm +++ b/NativeScript/ffi/objc/shared/bridge/host_objects/Class.mm @@ -213,6 +213,57 @@ throw JSError( } const auto& members = bridge_->membersForClass(symbol_); + // `[SomeClass appearance]` (and the whenContainedIn:/ + // appearanceWhenContainedIn: overloads) intercepted here so the returned + // opaque UIAppearance proxy gets tagged with the class it represents and + // has its property accessors installed — otherwise it would just be a + // callable selector-group function, not the property-settable object + // callers expect (see host_objects/Appearance.mm). + if (property == "appearance" && + selectorGroupEntriesForMethod(members, property, true) != nullptr) { + auto bridge = bridge_; + auto symbol = symbol_; + return Function::createFromHostFunction( + runtime, PropNameID::forAscii(runtime, property.c_str()), 0, + [bridge, symbol](Runtime& runtime, const Value&, + const Value* args, size_t count) -> Value { + Class cls = objc_lookUpClass(symbol.runtimeName.c_str()); + if (cls == Nil) { + throw JSError( + runtime, "Objective-C class is not available: " + + symbol.name); + } + + const auto& members = bridge->membersForClass(symbol); + const NativeApiMember* selected = + selectMethodMember(members, "appearance", true, count); + if (selected == nullptr) { + throw JSError(runtime, + "Objective-C selector is not available: appearance"); + } + + Value result = callObjCSelector( + runtime, bridge, static_cast(cls), true, + selected->selectorName, selected, args, count); + if (result.isObject()) { + Object resultObject = result.asObject(runtime); + if (resultObject.isHostObject( + runtime)) { + id native = resultObject + .getHostObject( + runtime) + ->object(); + Class customizableClass = + tagStaticAppearanceNativeResult(runtime, bridge, cls, + native); + installAppearanceProxyPropertyAccessors( + runtime, bridge, customizableClass, native, resultObject); + } + } + return result; + }); + } + if (const NativeApiMember* propertyMember = selectWritablePropertyMember(members, property, true)) { auto bridge = bridge_; @@ -307,7 +358,8 @@ throw JSError(runtime, Value makeNativeObjectValue(Runtime& runtime, const std::shared_ptr& bridge, - id object, bool ownsObject) { + id object, bool ownsObject, + Class superDispatchClass) { if (object == nil) { return Value::null(); } @@ -321,6 +373,9 @@ Value makeNativeObjectValue(Runtime& runtime, ? cached.asObject(runtime).getHostObject(runtime) : nullptr; if (cachedHost != nullptr && cachedHost->object() != nil) { + if (superDispatchClass != Nil) { + cachedHost->setSuperDispatchClass(superDispatchClass); + } if (ownsObject) { [object release]; } @@ -331,7 +386,8 @@ Value makeNativeObjectValue(Runtime& runtime, Object result = createNativeInstanceHostObject( runtime, - std::make_shared(bridge, object, ownsObject)); + std::make_shared( + bridge, object, ownsObject, superDispatchClass)); Value prototypeValue = Value::undefined(); Value classWrapperValue = bridge->findObjectExpando(runtime, object, "__nativeApiClassWrapper"); diff --git a/NativeScript/ffi/objc/shared/bridge/host_objects/Object.mm b/NativeScript/ffi/objc/shared/bridge/host_objects/Object.mm index 2fd5b80be..dede0e2a0 100644 --- a/NativeScript/ffi/objc/shared/bridge/host_objects/Object.mm +++ b/NativeScript/ffi/objc/shared/bridge/host_objects/Object.mm @@ -130,12 +130,8 @@ NativeApiSymbol nativeApiSymbolForRuntimeClass( return std::nullopt; } -std::optional runtimeReadablePropertyGetter(id object, - const std::string& property) { - if (object == nil || property.empty()) { - return std::nullopt; - } - +std::optional resolveRuntimeReadablePropertyGetter( + id object, const std::string& property) { Class current = object_getClass(object); while (current != Nil) { objc_property_t prop = class_getProperty(current, property.c_str()); @@ -158,6 +154,71 @@ NativeApiSymbol nativeApiSymbolForRuntimeClass( return respondingPropertyGetterSelector(object, property, property); } +// Caches the resolved getter selector per (class, property): this path only +// serves JS-subclass instances (the hot metadata-getter path already has +// findCachedPropertyGetter in front, see below), but the objc-runtime class +// walk above is still worth amortizing across repeated accesses. +std::optional runtimeReadablePropertyGetter(id object, + const std::string& property) { + if (object == nil || property.empty()) { + return std::nullopt; + } + + Class cls = object_getClass(object); + static std::mutex cacheMutex; + static std::unordered_map>> + cache; + + { + std::lock_guard lock(cacheMutex); + auto classIt = cache.find(cls); + if (classIt != cache.end()) { + auto propertyIt = classIt->second.find(property); + if (propertyIt != classIt->second.end()) { + return propertyIt->second; + } + } + } + + std::optional resolved = + resolveRuntimeReadablePropertyGetter(object, property); + + std::lock_guard lock(cacheMutex); + cache[cls][property] = resolved; + return resolved; +} + +// True if `property` resolves to a real runtime getter (metadata property or +// a JS-subclass instance's own class-builder-registered getter) — used to +// decide whether a successful runtime SET also needs an expando write so a +// subsequent GET (which may not consult the same runtime path) sees it. +bool objectGetPathCanReadRuntimeProperty(id object, + const std::string& property) { + if (object == nil || property.empty()) { + return false; + } + + if (class_conformsToProtocol(object_getClass(object), + @protocol(NativeApiClassBuilderProtocol))) { + return runtimeReadablePropertyGetter(object, property).has_value(); + } + + if (objc_property_t prop = + class_getProperty(object_getClass(object), property.c_str())) { + std::string getter = property; + if (char* customGetter = property_copyAttributeValue(prop, "G")) { + getter = customGetter; + free(customGetter); + } + return respondingPropertyGetterSelector(object, property, getter) + .has_value(); + } + + return false; +} + class NativeApiSuperHostObject final : public HostObject { public: NativeApiSuperHostObject(std::shared_ptr bridge, @@ -467,10 +528,12 @@ Array runtimeMembersArray(Runtime& runtime, Class cls, bool staticMembers) { public std::enable_shared_from_this { public: NativeApiObjectHostObject(std::shared_ptr bridge, - id object, bool ownsObject) + id object, bool ownsObject, + Class superDispatchClass = Nil) : bridge_(std::move(bridge)), object_(object), ownsObject_(ownsObject), + superDispatchClass_(superDispatchClass), lifetimeState_(std::make_shared(object)) { if (bridge_ != nullptr && object_ != nil) { bridge_->retainObjectExpandoOwner(object_); @@ -499,6 +562,9 @@ Array runtimeMembersArray(Runtime& runtime, Class cls, bool staticMembers) { } id object() const { return object_; } + void setSuperDispatchClass(Class superDispatchClass) { + superDispatchClass_ = superDispatchClass; + } std::shared_ptr lifetimeState() const { return lifetimeState_; } @@ -528,6 +594,36 @@ void disownObject(id expected, bool preserveExpandos = false) { } } + // Disown without forgetting the round-trip value: used when a wrapper is + // being replaced (e.g. an initializer returned a different/self receiver) + // but the native receiver itself is staying alive and must keep resolving + // to the SAME preserved engine value on the next lookup. Unlike + // disownObject(), this does not call forgetRoundTripValue — losing that + // would let a second, divergent wrapper get created for the same native + // receiver. releaseObjectExpandoOwner still runs (to keep the refcount + // balanced against the matching retain in the constructor), but with + // preserveExpandos=true so the receiver's expando state also survives. + void detachObjectPreservingBridgeState(id expected) { + if (object_ != expected) { + return; + } + + id object = object_; + bool releaseObject = ownsObject_; + if (bridge_ != nullptr && expected != nil) { + bridge_->releaseObjectExpandoOwner(expected, /*preserveExpandos=*/true); + } + ownsObject_ = false; + wrapperRetainedObject_ = false; + object_ = nil; + if (lifetimeState_ != nullptr) { + lifetimeState_->clear(); + } + if (releaseObject && object != nil) { + [object release]; + } + } + static bool isInitializerSelector(const std::string& selectorName) { return selectorName.rfind("init", 0) == 0; } @@ -691,7 +787,22 @@ Value classPrototypeForObject(Runtime& runtime) { return prototypeValue; } } - return bridge_->findClassPrototype(runtime, object_getClass(object_)); + Value prototypeValue = + bridge_->findClassPrototype(runtime, object_getClass(object_)); + if (prototypeValue.isObject()) { + return prototypeValue; + } + // Fallback for classes only known by symbol name (not yet indexed by + // runtime pointer — RN disables eager runtime-pointer indexing). + if (const NativeApiSymbol* symbol = + bridge_->findClassForRuntimeClass(object_getClass(object_))) { + prototypeValue = bridge_->findClassPrototype( + runtime, objc_lookUpClass(symbol->runtimeName.c_str())); + if (prototypeValue.isObject()) { + return prototypeValue; + } + } + return Value::undefined(); } Value engineThisValueForObject(Runtime& runtime) { @@ -847,6 +958,13 @@ Value get(Runtime& runtime, const PropNameID& name) override { if (!expando.isUndefined()) { return expando; } + // If this receiver is a UIAppearance proxy, its properties live in the + // class-keyed appearance cache, not on the object itself. + Value appearanceExpando = + cachedAppearanceProxyPropertyValue(runtime, bridge_, object_, property); + if (!appearanceExpando.isUndefined()) { + return appearanceExpando; + } // Fast path: cached metadata property-getter resolution. Skips the // special-name chain + per-access metadata discovery for hot getters @@ -982,8 +1100,15 @@ Value get(Runtime& runtime, const PropNameID& name) override { return makeNativeClassValue(runtime, bridge_, std::move(symbol)); } if (property == "super") { + // A JS-subclass instance dispatches `$base`/super against the ObjC + // class it was constructed to extend, not necessarily the receiver's + // immediate runtime superclass (which can differ, e.g. after a + // preserved initializer self-return re-seated the wrapper). Class dispatchClass = - object_ != nil ? class_getSuperclass(object_getClass(object_)) : Nil; + superDispatchClass_ != Nil + ? superDispatchClass_ + : (object_ != nil ? class_getSuperclass(object_getClass(object_)) + : Nil); return Object::createFromHostObject( runtime, std::make_shared(bridge_, object_, @@ -1234,20 +1359,50 @@ throw JSError( // methods); defer so the engine resolves them instead of the bridge // returning a registered getter IMP as a raw callable. if (isEngineExtendedInstance) { -#ifdef NATIVESCRIPT_NATIVE_API_HOST_EXPLICIT_OVERRIDE - // Engines whose exotic property handler invokes prototype accessors with - // the wrong receiver need the JS-prototype getter resolved here with this - // instance as the receiver. + // Prefer JS prototype accessors before falling back to runtime ObjC + // getters; otherwise an ObjC getter implemented by the JS subclass can + // re-enter the same JS accessor recursively. bool found = false; Value resolved = resolveEnginePrototypeGetter(runtime, property, &found); if (found) { return resolved; } -#endif if (auto selector = runtimeReadablePropertyGetter(object_, property)) { return callObjectSelector(runtime, *selector, nullptr, nullptr, 0); } + // Inherited ObjC selectors on a ClassBuilder subclass have no JS + // prototype entry (the engine may register an empty prototype for the + // subclass) and no runtime ObjC property, so the getter probes above + // miss. Fall through to metadata METHOD resolution using the nearest + // metadata ancestor (findClassForRuntimeClass walks up from the concrete + // subclass, which itself carries no metadata) so first-access inherited + // selectors resolve as bound selector-group functions instead of hard- + // returning undefined. This mirrors the non-extended method path above. + // Property accessors stay deferred here on purpose (accessor shadowing / + // reentry suppression), so resolve METHODS ONLY — JS-overridden methods + // are already handled earlier via the JS prototype chain. + if (const NativeApiSymbol* symbol = + bridge_->findClassForRuntimeClass(object_getClass(object_))) { + const auto& members = bridge_->membersForClass(*symbol); + if (hasMethodMember(members, property, false)) { + auto selectors = + selectorGroupEntriesForMethod(members, property, false); + if (selectors != nullptr) { + auto preparedInvocations = std::make_shared>>( + selectors->size()); + Value methodFunction = CreateNativeApiBoundSelectorGroupFunction( + runtime, bridge_, object_getClass(object_), shared_from_this(), + selectors, preparedInvocations); + // Cache the resolved host function so repeated method access does + // not reallocate it on every call (hot path). + bridge_->setObjectExpando(runtime, object_, property, + methodFunction); + return methodFunction; + } + } + } return Value::undefined(); } @@ -1302,6 +1457,54 @@ NativeApiHostSetResult set(Runtime& runtime, const PropNameID& name, const Value throw JSError(runtime, "Cannot set property on nil object."); } + bool isEngineExtendedInstance = + class_conformsToProtocol(object_getClass(object_), + @protocol(NativeApiClassBuilderProtocol)); + // A JS accessor override must win over a native ObjC setter: try it + // first, before any of the metadata/runtime setter paths below. + if (isEngineExtendedInstance) { + if (invokeEnginePrototypeSetter(runtime, property, value)) { + NATIVE_API_SET_RETURN(true); + } + } + + // If this receiver is a UIAppearance proxy, its properties are recorded + // in the class-keyed appearance cache, not set through the metadata/ + // runtime setter paths below (an appearance proxy setter doesn't + // reliably support read-your-write, so we cache it ourselves too). + if (Class appearanceClass = + taggedAppearanceProxyClass(runtime, bridge_, object_)) { + if (const NativeApiSymbol* symbol = + bridge_->findClassForRuntimeClass(appearanceClass)) { + const auto& members = bridge_->membersForClass(*symbol); + if (const NativeApiMember* propertyMember = + selectAppearanceProxyPropertyMember(members, property)) { + if (propertyMember->readonly) { + throw JSError( + runtime, "Attempted to assign to readonly property."); + } + Value args[] = {Value(runtime, value)}; + if (!propertyMember->setterSelectorName.empty()) { + NativeApiMember setterMember = *propertyMember; + setterMember.selectorName = propertyMember->setterSelectorName; + setterMember.signatureOffset = propertyMember->setterSignatureOffset; + callObjCSelector(runtime, bridge_, object_, false, + setterMember.selectorName, &setterMember, args, 1); + } else if (auto setterSelectorName = + runtimeWritablePropertySetter(object_, property)) { + callObjCSelector(runtime, bridge_, object_, false, + *setterSelectorName, nullptr, args, 1); + } else { + throw JSError( + runtime, "UIAppearance property setter is unavailable."); + } + cacheAppearanceProxyPropertyValue(runtime, bridge_, object_, + property, value); + NATIVE_API_SET_RETURN(true); + } + } + } + if (const NativeApiSymbol* symbol = bridge_->findClassForRuntimeClass(object_getClass(object_))) { const auto& members = bridge_->membersForClass(*symbol); @@ -1317,6 +1520,8 @@ throw JSError( Value args[] = {Value(runtime, value)}; callObjCSelector(runtime, bridge_, object_, false, setterMember.selectorName, &setterMember, args, 1); + cacheAppearanceProxyPropertyValue(runtime, bridge_, object_, property, + value); NATIVE_API_SET_RETURN(true); } } @@ -1326,14 +1531,22 @@ throw JSError( Value args[] = {Value(runtime, value)}; callObjCSelector(runtime, bridge_, object_, false, *setterSelectorName, nullptr, args, 1); + cacheAppearanceProxyPropertyValue(runtime, bridge_, object_, property, + value); + // The property was set through a runtime-discovered setter, but the + // GET path may not find a matching readable getter (e.g. a + // write-only or asymmetrically-named property) — write an expando + // too so a subsequent get() still sees this value. + if (!objectGetPathCanReadRuntimeProperty(object_, property)) { + bridge_->setObjectExpando(runtime, object_, property, value); + } NATIVE_API_SET_RETURN(true); } // For JS-subclassed instances, an unknown property is owned by the JS // prototype (e.g. a JS-defined accessor); defer so the engine runs it instead of // shadowing it with a bridge expando. - if (class_conformsToProtocol(object_getClass(object_), - @protocol(NativeApiClassBuilderProtocol))) { + if (isEngineExtendedInstance) { #ifdef NATIVESCRIPT_NATIVE_API_HOST_EXPLICIT_OVERRIDE // Engines whose exotic property storage doesn't fall back to own // properties need the JS-owned set resolved here: invoke a JS-prototype @@ -1345,6 +1558,10 @@ throw JSError( } NATIVE_API_SET_RETURN(true); #else + // The prototype-setter attempt at the top of set() already ran and + // didn't return — reaching here means no JS setter fired, so store the + // expando unconditionally instead of re-probing for one. + storeOwnExpando(runtime, property, value); NATIVE_API_SET_RETURN(false); #endif } @@ -1376,5 +1593,39 @@ throw JSError( bool ownsObject_ = false; bool wrapperRetainedObject_ = false; bool consumed_ = false; + // Set when this wrapper represents a JS-subclass instance whose `$base`/ + // super dispatch must resolve against a specific ObjC superclass (rather + // than the receiver's own class) — see the "super" property handling in + // get() below. + Class superDispatchClass_ = Nil; std::shared_ptr lifetimeState_; }; + +// Tags a `[SomeClass appearance]`-family call's result (a wrapped +// UIAppearance proxy) with the class it proxies for, and installs the +// property accessors that make it behave like a real object rather than +// requiring `.invoke(...)`. Lives here (not host_objects/Appearance.mm) +// because it needs the complete NativeApiObjectHostObject type; Class.mm, +// Protocol.mm, Invocation.mm, and the per-engine selector-group code all +// consume it and are included later in the bridge. +Value tagStaticAppearanceSelectorResult( + Runtime& runtime, const std::shared_ptr& bridge, + id receiver, bool receiverIsClass, const std::string& selectorName, + Value result) { + if (!isStaticAppearanceSelector(receiverIsClass, selectorName) || + !result.isObject()) { + return result; + } + Object resultObject = result.asObject(runtime); + if (!resultObject.isHostObject(runtime)) { + return result; + } + Class customizableClass = tagStaticAppearanceNativeResult( + runtime, bridge, static_cast(receiver), + resultObject.getHostObject(runtime)->object()); + installAppearanceProxyPropertyAccessors( + runtime, bridge, customizableClass, + resultObject.getHostObject(runtime)->object(), + resultObject); + return result; +} diff --git a/NativeScript/ffi/objc/shared/bridge/host_objects/Protocol.mm b/NativeScript/ffi/objc/shared/bridge/host_objects/Protocol.mm index 5c689f627..cf480883b 100644 --- a/NativeScript/ffi/objc/shared/bridge/host_objects/Protocol.mm +++ b/NativeScript/ffi/objc/shared/bridge/host_objects/Protocol.mm @@ -210,6 +210,11 @@ Value makeProtocolPropertyGetter(Runtime& runtime, NativeApiMember member, throw JSError( runtime, "Protocol property requires a native receiver."); } + Value appearanceExpando = cachedAppearanceProxyPropertyValue( + runtime, bridge, receiver, member.name); + if (!appearanceExpando.isUndefined()) { + return appearanceExpando; + } NativeApiMember getterMember = member; if (auto selector = respondingPropertyGetterSelector( receiver, member.name, member.selectorName)) { @@ -255,9 +260,12 @@ throw JSError( NativeApiMember setterMember = member; setterMember.selectorName = member.setterSelectorName; setterMember.signatureOffset = member.setterSignatureOffset; - return callObjCSelector(runtime, bridge, receiver, receiverIsClass, - setterMember.selectorName, &setterMember, - args, 1); + Value result = callObjCSelector( + runtime, bridge, receiver, receiverIsClass, + setterMember.selectorName, &setterMember, args, 1); + cacheAppearanceProxyPropertyValue(runtime, bridge, receiver, + member.name, args[0]); + return result; }); } diff --git a/NativeScript/ffi/objc/v8/NativeApiV8.mm b/NativeScript/ffi/objc/v8/NativeApiV8.mm index 1ed1c20e0..8b1108488 100644 --- a/NativeScript/ffi/objc/v8/NativeApiV8.mm +++ b/NativeScript/ffi/objc/v8/NativeApiV8.mm @@ -5,6 +5,8 @@ #include "NativeApiV8Runtime.h" #include "SignatureDispatch.h" +#include "../shared/bridge/InteropProfiler.h" + namespace nativescript { namespace { diff --git a/NativeScript/ffi/objc/v8/NativeApiV8HostObjects.mm b/NativeScript/ffi/objc/v8/NativeApiV8HostObjects.mm index b71245931..e21c85c52 100644 --- a/NativeScript/ffi/objc/v8/NativeApiV8HostObjects.mm +++ b/NativeScript/ffi/objc/v8/NativeApiV8HostObjects.mm @@ -10,6 +10,106 @@ Value valueFromLocal(Runtime& runtime, v8::Local value) { return Value(runtime, value); } +// V8's named-property interceptors run BEFORE prototype-chain lookup, so a +// JS-subclass accessor defined on the prototype (not the host object itself) +// would otherwise be shadowed by the interceptor. These let the get/set +// interceptors below check the prototype chain first and defer to it when a +// descriptor is found there. +bool findPrototypeDescriptor(Runtime& runtime, v8::Local object, + v8::Local property, + v8::Local* descriptorOut) { + v8::TryCatch tryCatch(runtime.isolate()); + v8::Local currentValue = object->GetPrototypeV2(); + for (size_t depth = 0; depth < 64 && currentValue->IsObject(); depth++) { + v8::Local current = currentValue.As(); + v8::Local descriptorValue; + if (!current->GetOwnPropertyDescriptor(runtime.context(), property) + .ToLocal(&descriptorValue)) { + throw JSError(runtime, + currentExceptionMessage(runtime.isolate(), tryCatch)); + } + if (descriptorValue->IsObject()) { + *descriptorOut = descriptorValue.As(); + return true; + } + currentValue = current->GetPrototypeV2(); + } + return false; +} + +bool tryResolvePrototypeGet(Runtime& runtime, v8::Local object, + v8::Local receiver, + v8::Local property, + v8::Local* resultOut) { + v8::Local descriptor; + if (!findPrototypeDescriptor(runtime, object, property, &descriptor)) { + return false; + } + + v8::TryCatch tryCatch(runtime.isolate()); + v8::Local getKey = makeV8String(runtime.isolate(), "get"); + v8::Local getterValue; + if (!descriptor->Get(runtime.context(), getKey).ToLocal(&getterValue)) { + throw JSError(runtime, currentExceptionMessage(runtime.isolate(), tryCatch)); + } + if (getterValue->IsFunction()) { + v8::Local result; + if (!getterValue.As() + ->Call(runtime.context(), receiver, 0, nullptr) + .ToLocal(&result)) { + throw JSError(runtime, + currentExceptionMessage(runtime.isolate(), tryCatch)); + } + *resultOut = result; + return true; + } + + v8::Local valueKey = makeV8String(runtime.isolate(), "value"); + bool hasValue = + descriptor->HasOwnProperty(runtime.context(), valueKey).FromMaybe(false); + if (hasValue) { + v8::Local value; + if (!descriptor->Get(runtime.context(), valueKey).ToLocal(&value)) { + throw JSError(runtime, + currentExceptionMessage(runtime.isolate(), tryCatch)); + } + *resultOut = value; + return true; + } + + *resultOut = v8::Undefined(runtime.isolate()); + return true; +} + +bool tryInvokePrototypeSetter(Runtime& runtime, v8::Local object, + v8::Local receiver, + v8::Local property, + v8::Local value) { + v8::Local descriptor; + if (!findPrototypeDescriptor(runtime, object, property, &descriptor)) { + return false; + } + + v8::TryCatch tryCatch(runtime.isolate()); + v8::Local setKey = makeV8String(runtime.isolate(), "set"); + v8::Local setterValue; + if (!descriptor->Get(runtime.context(), setKey).ToLocal(&setterValue)) { + throw JSError(runtime, currentExceptionMessage(runtime.isolate(), tryCatch)); + } + if (!setterValue->IsFunction()) { + return false; + } + + v8::Local args[] = {value}; + v8::Local ignored; + if (!setterValue.As() + ->Call(runtime.context(), receiver, 1, args) + .ToLocal(&ignored)) { + throw JSError(runtime, currentExceptionMessage(runtime.isolate(), tryCatch)); + } + return true; +} + v8::Local hostObjectTemplate(Runtime& runtime) { auto state = runtime.state(); if (state->hostObjectTemplate.IsEmpty()) { @@ -214,6 +314,15 @@ if (*utf8 == nullptr) { return v8::Intercepted::kNo; } + v8::Local holderObject = info.Holder(); + v8::Local receiver = + info.This()->IsObject() ? info.This().As() : holderObject; + v8::Local prototypeResult; + if (tryResolvePrototypeGet(runtime, holderObject, receiver, + property, &prototypeResult)) { + info.GetReturnValue().Set(prototypeResult); + return v8::Intercepted::kYes; + } Value result = holder->hostObject->get( runtime, PropNameID(std::string(*utf8, utf8.length()))); if (!result.isUndefined()) { @@ -243,6 +352,13 @@ if (*utf8 == nullptr) { return v8::Intercepted::kNo; } + v8::Local holderObject = info.Holder(); + v8::Local receiver = + info.This()->IsObject() ? info.This().As() : holderObject; + if (tryInvokePrototypeSetter(runtime, holderObject, receiver, + property, value)) { + return v8::Intercepted::kYes; + } bool handled = holder->hostObject->set( runtime, PropNameID(std::string(*utf8, utf8.length())), Value(runtime, value)); diff --git a/NativeScript/ffi/objc/v8/NativeApiV8SelectorGroups.mm b/NativeScript/ffi/objc/v8/NativeApiV8SelectorGroups.mm index 66e621304..d9da90217 100644 --- a/NativeScript/ffi/objc/v8/NativeApiV8SelectorGroups.mm +++ b/NativeScript/ffi/objc/v8/NativeApiV8SelectorGroups.mm @@ -204,9 +204,12 @@ void NativeApiSelectorGroupCallback( // Inline GSD fast path: skip the setV8EnginePreparedObjCResult call and its // argument-count/NSError preamble entirely for the common case. The // generated invoker reads args, calls objc_msgSend, and sets the return. + // Excludes appearance static selectors (gsdAllowed) — those need the + // generic path's proxy tagging. if (call.prepared->gsdEngineCallable && call.dispatchClass == Nil && !call.prepared->isInitMethod && - count == call.prepared->gsdEngineArgumentCount) { + count == call.prepared->gsdEngineArgumentCount && + call.gsdAllowed) { auto invoker = reinterpret_cast(call.prepared->engineInvoker); GsdObjCContext ctx{runtime, @@ -218,6 +221,12 @@ void NativeApiSelectorGroupCallback( runtime.context(), call.prepared->signature.returnType}; if (invoker(ctx)) { + if (count > 0) { + Value setterValue = Value::borrowed(runtime, info[0]); + cachePreparedAppearanceProxySetterValue(runtime, data->bridge, + call.receiver, *call.prepared, + &setterValue, 1); + } return; } } @@ -225,6 +234,14 @@ void NativeApiSelectorGroupCallback( runtime, data->bridge, call.receiver, *call.prepared, call.receiverHostObject, call.initializerClassWrapper, info, call.dispatchClass); + if (!data->receiverIsClass && call.prepared->isInitMethod) { + if (auto preserved = preservedNativeApiInitializerSelfReturn( + runtime, data->bridge, call.receiver, + Value(runtime, info.GetReturnValue().Get()), + Value(runtime, info.This()))) { + info.GetReturnValue().Set(preserved->local(runtime)); + } + } } catch (const std::exception& exception) { engine::v8engine::throwV8Exception(info.GetIsolate(), exception); } diff --git a/NativeScript/runtime/apple/NativeScript.mm b/NativeScript/runtime/apple/NativeScript.mm index 12cc152e1..b678d16eb 100644 --- a/NativeScript/runtime/apple/NativeScript.mm +++ b/NativeScript/runtime/apple/NativeScript.mm @@ -1,10 +1,12 @@ #include "NativeScript.h" #include "Runtime.h" #include "RuntimeConfig.h" +#include "cli/BundleLoader.h" #include "runtime/apple/NativeScriptException.h" #include "ffi/objc/shared/Tasks.h" #include "js_native_api.h" #include "jsr.h" +#include using namespace nativescript; @@ -21,7 +23,18 @@ @implementation NativeScript extern char defaultStartOfMetadataSection __asm("section$start$__DATA$__TNSMetadata"); -std::unique_ptr runtime_; +// Raw pointer, not unique_ptr: at process exit, static-destruction order is +// unspecified relative to the ObjC runtime/other statics, so an implicit +// unique_ptr destructor can run after dependencies it needs are already torn +// down. resetRuntime() gives us an explicit, ordered teardown point, and +// restartWithConfig: needs the old runtime to stay alive until the new one +// has finished Init() (see below). +Runtime* runtime_ = nullptr; + +static void resetRuntime() { + delete runtime_; + runtime_ = nullptr; +} - (void)runScriptString:(NSString*)script runLoop:(BOOL)runLoop { std::string cppScript = [script UTF8String]; @@ -33,7 +46,10 @@ - (void)runScriptString:(NSString*)script runLoop:(BOOL)runLoop { } - (void)runMainApplication { - std::string spec = "./app/index.js"; + std::string spec = resolveMainPath(); + if (spec.empty()) { + spec = "./app/index.js"; + } try { runtime_->RunModule(spec); } catch (const NativeScriptException& e) { @@ -118,7 +134,7 @@ - (bool)liveSync { } - (void)shutdownRuntime { - runtime_ = nullptr; + resetRuntime(); } - (instancetype)initWithConfig:(Config*)config { @@ -154,10 +170,15 @@ - (instancetype)initWithConfig:(Config*)config { RuntimeConfig.LogToSystemConsole = [config LogToSystemConsole]; RuntimeConfig.CustomLogCallback = [config CustomLogCallback]; - runtime_ = std::make_unique(); + // Build and Init the new runtime before tearing down the old one — the + // old runtime (and anything it holds live, e.g. in-flight callbacks) + // must outlive the new runtime's Init() on restartWithConfig:. + std::unique_ptr runtime(new Runtime()); // TODO: separate runtime init and measure the time - runtime_->Init(); + runtime->Init(); + resetRuntime(); + runtime_ = runtime.release(); if (RuntimeConfig.IsDebug) { // TODO: Inspector for debugging diff --git a/NativeScript/runtime/apple/ThreadSafeFunction.mm b/NativeScript/runtime/apple/ThreadSafeFunction.mm index ee7953748..6f0e0bb6e 100644 --- a/NativeScript/runtime/apple/ThreadSafeFunction.mm +++ b/NativeScript/runtime/apple/ThreadSafeFunction.mm @@ -98,10 +98,20 @@ typedef void(NAPI_CDECL* napi_async_cleanup_hook)( bool draining_async_hooks = false; }; -static std::mutex g_cleanup_hooks_mutex; -static std::condition_variable g_cleanup_hooks_cv; -static std::unordered_map - g_cleanup_hooks; +static std::mutex& CleanupHooksMutex() { + static auto* mutex = new std::mutex(); + return *mutex; +} + +static std::condition_variable& CleanupHooksCV() { + static auto* cv = new std::condition_variable(); + return *cv; +} + +static std::unordered_map& CleanupHooks() { + static auto* hooks = new std::unordered_map(); + return *hooks; +} static bool IsCleanupStateEmpty(const EnvCleanupState& state) { return state.env_hooks.empty() && state.async_hooks.empty() && @@ -109,9 +119,10 @@ static bool IsCleanupStateEmpty(const EnvCleanupState& state) { } static void EraseCleanupStateIfUnused(node_api_basic_env env) { - auto it = g_cleanup_hooks.find(env); - if (it != g_cleanup_hooks.end() && IsCleanupStateEmpty(it->second)) { - g_cleanup_hooks.erase(it); + auto& cleanupHooks = CleanupHooks(); + auto it = cleanupHooks.find(env); + if (it != cleanupHooks.end() && IsCleanupStateEmpty(it->second)) { + cleanupHooks.erase(it); } } @@ -472,8 +483,8 @@ static void ExecuteTSFNCall(const std::shared_ptr& call) { return napi_invalid_arg; } - std::lock_guard lock(g_cleanup_hooks_mutex); - auto& state = g_cleanup_hooks[env]; + std::lock_guard lock(CleanupHooksMutex()); + auto& state = CleanupHooks()[env]; state.env_hooks.emplace_back(fun, arg); return napi_ok; } @@ -485,9 +496,10 @@ static void ExecuteTSFNCall(const std::shared_ptr& call) { return napi_invalid_arg; } - std::lock_guard lock(g_cleanup_hooks_mutex); - auto it = g_cleanup_hooks.find(env); - if (it == g_cleanup_hooks.end()) { + std::lock_guard lock(CleanupHooksMutex()); + auto& cleanupHooks = CleanupHooks(); + auto it = cleanupHooks.find(env); + if (it == cleanupHooks.end()) { return napi_invalid_arg; } @@ -516,8 +528,8 @@ static void ExecuteTSFNCall(const std::shared_ptr& call) { handle->hook = hook; handle->data = arg; - std::lock_guard lock(g_cleanup_hooks_mutex); - auto& state = g_cleanup_hooks[env]; + std::lock_guard lock(CleanupHooksMutex()); + auto& state = CleanupHooks()[env]; state.async_hooks.push_back(handle); if (remove_handle != nullptr) { *remove_handle = handle; @@ -534,9 +546,10 @@ static void ExecuteTSFNCall(const std::shared_ptr& call) { auto* handle = static_cast(remove_handle); node_api_basic_env env = handle->env; - std::lock_guard lock(g_cleanup_hooks_mutex); - auto it = g_cleanup_hooks.find(env); - if (it == g_cleanup_hooks.end() || handle->removed) { + std::lock_guard lock(CleanupHooksMutex()); + auto& cleanupHooks = CleanupHooks(); + auto it = cleanupHooks.find(env); + if (it == cleanupHooks.end() || handle->removed) { return napi_invalid_arg; } @@ -553,7 +566,7 @@ static void ExecuteTSFNCall(const std::shared_ptr& call) { if (state.draining_async_hooks) { state.deferred_delete_async_hooks.push_back(handle); if (state.async_hooks.empty()) { - g_cleanup_hooks_cv.notify_all(); + CleanupHooksCV().notify_all(); } } else { delete handle; @@ -571,9 +584,10 @@ void js_run_env_cleanup_hooks(napi_env env) { std::vector> env_hooks_to_run; std::vector async_hooks_to_run; { - std::lock_guard lock(g_cleanup_hooks_mutex); - auto state_it = g_cleanup_hooks.find(env); - if (state_it == g_cleanup_hooks.end()) { + std::lock_guard lock(CleanupHooksMutex()); + auto& cleanupHooks = CleanupHooks(); + auto state_it = cleanupHooks.find(env); + if (state_it == cleanupHooks.end()) { return; } @@ -596,9 +610,10 @@ void js_run_env_cleanup_hooks(napi_env env) { void* data = nullptr; bool should_invoke = false; { - std::lock_guard lock(g_cleanup_hooks_mutex); - auto state_it = g_cleanup_hooks.find(env); - if (state_it == g_cleanup_hooks.end()) { + std::lock_guard lock(CleanupHooksMutex()); + auto& cleanupHooks = CleanupHooks(); + auto state_it = cleanupHooks.find(env); + if (state_it == cleanupHooks.end()) { break; } @@ -619,15 +634,17 @@ void js_run_env_cleanup_hooks(napi_env env) { std::vector handles_to_delete; { - std::unique_lock lock(g_cleanup_hooks_mutex); - g_cleanup_hooks_cv.wait(lock, [&]() { - auto state_it = g_cleanup_hooks.find(env); - return state_it == g_cleanup_hooks.end() || + std::unique_lock lock(CleanupHooksMutex()); + CleanupHooksCV().wait(lock, [&]() { + auto& cleanupHooks = CleanupHooks(); + auto state_it = cleanupHooks.find(env); + return state_it == cleanupHooks.end() || state_it->second.async_hooks.empty(); }); - auto state_it = g_cleanup_hooks.find(env); - if (state_it == g_cleanup_hooks.end()) { + auto& cleanupHooks = CleanupHooks(); + auto state_it = cleanupHooks.find(env); + if (state_it == cleanupHooks.end()) { return; } @@ -636,7 +653,7 @@ void js_run_env_cleanup_hooks(napi_env env) { handles_to_delete.swap(state.deferred_delete_async_hooks); if (IsCleanupStateEmpty(state)) { - g_cleanup_hooks.erase(state_it); + cleanupHooks.erase(state_it); } } diff --git a/packages/objc-node-api/index.d.ts b/packages/objc-node-api/index.d.ts index 021f11abb..898b4e5e7 100644 --- a/packages/objc-node-api/index.d.ts +++ b/packages/objc-node-api/index.d.ts @@ -94,6 +94,16 @@ declare global { export type Enum<_T extends Record> = number; + export type AssociationPolicy = + | "assign" + | "retain" + | "retainNonatomic" + | "strong" + | "strongNonatomic" + | "copy" + | "copyNonatomic" + | number; + export function addMethod< T extends abstract new (...args: unknown[]) => unknown, >( @@ -109,6 +119,16 @@ declare global { export function sizeof(obj: unknown): number; export function alloc(size: number): Pointer; export function handleof(obj: unknown): Pointer; + export function setAssociatedObject( + target: NativeObject | Pointer | string | number, + key: string, + value: unknown, + policy?: AssociationPolicy, + ): void; + export function getAssociatedObject( + target: NativeObject | Pointer | string | number | null | undefined, + key: string, + ): T | null; export function bufferFromData(data: NativeObject): ArrayBuffer; } } diff --git a/packages/react-native/NativeScriptNativeApi.podspec b/packages/react-native/NativeScriptNativeApi.podspec index 4466de43e..1ed506c73 100644 --- a/packages/react-native/NativeScriptNativeApi.podspec +++ b/packages/react-native/NativeScriptNativeApi.podspec @@ -27,6 +27,54 @@ Pod::Spec.new do |s| s.resource_bundles = { "NativeScriptNativeApi" => ["metadata/*.nsmd"] } + s.script_phase = { + :name => "Prune NativeScript metadata resources", + :execution_position => :after_compile, + :script => <<-'SCRIPT' +set -e + +bundle="${BUILT_PRODUCTS_DIR}/NativeScriptNativeApi.bundle" +if [ ! -d "$bundle" ]; then + bundle="${TARGET_BUILD_DIR}/NativeScriptNativeApi.bundle" +fi +if [ ! -d "$bundle" ]; then + exit 0 +fi + +keep=" " +case "$PLATFORM_NAME" in + iphoneos) + keep="${keep}metadata.ios.arm64.nsmd " + ;; + iphonesimulator) + archs="${ARCHS:-$CURRENT_ARCH}" + for arch in $archs; do + case "$arch" in + arm64|x86_64) + keep="${keep}metadata.ios-sim.$arch.nsmd " + ;; + esac + done + ;; +esac + +if [ "$keep" = " " ]; then + exit 0 +fi + +for file in "$bundle"/metadata*.nsmd; do + [ -e "$file" ] || continue + name="$(basename "$file")" + case "$keep" in + *" $name "*) + ;; + *) + rm -f "$file" + ;; + esac +done +SCRIPT + } s.vendored_frameworks = "ios/vendor/Libffi.xcframework" s.compiler_flags = folly_compiler_flags diff --git a/packages/react-native/README.md b/packages/react-native/README.md index b9ebc174c..9a17aa8b1 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -1,41 +1,59 @@ # @nativescript/react-native -React Native TurboModule wrapper for the NativeScript Native API JSI bridge on -Hermes. - -The module exposes one small TurboModule whose `init()` method attaches the -NativeScript Native API host object to `globalThis.__nativeScriptNativeApi` and -installs lazy NativeScript-style globals for classes and C functions. The host -object itself is pure JSI and is shared with the NativeScript Hermes runtime. +A TurboModule + Fabric host that lets you drive real UIKit from TypeScript. It +installs the NativeScript Native API (the JSI Objective‑C interop used by the +NativeScript Hermes runtime) into a React Native app, and gives you a small set +of host factories for wrapping native `UIView`s and `UIViewController`s as React +components. The native work runs in worklets on the UI runtime, so UIKit is +touched on the right thread with the same globals and generated iOS SDK types +NativeScript uses. ```ts import NativeScript from "@nativescript/react-native"; NativeScript.init(); -const object = NSObject.new(); -``` - -`NativeScript.init()` also installs the Native API into the -`react-native-worklets` UI runtime. `NativeScript.runOnUI()` only accepts -Worklets callbacks; running React Native's JS-thread runtime as a UI-thread shim -is not supported. - -```ts await NativeScript.runOnUI(() => { "worklet"; UIApplication.sharedApplication.keyWindow.tintColor = UIColor.systemPinkColor; }); ``` -Install `react-native-worklets`, add its Babel plugin, and run `pod install` so -the `RNWorklets` pod is linked: +This package is intentionally low‑level. It installs the interop and gives you +lifecycle helpers; it ships no opinionated wrappers for tabs, maps, cameras, or +navigation. Build those as components in your app, or on top of +[`@nativescript/react-native-screens`](../react-native-screens) (the thin +`react-native-screens` adapter that this engine powers). + +Requires Hermes and the New Architecture (Fabric + TurboModules). + +--- + +## `init()` and the Babel plugin + +`NativeScript.init()` attaches the Native API host object to +`globalThis.__nativeScriptNativeApi`, installs the lazy NativeScript‑style class +and C‑function globals, and installs the Native API into the +`react-native-worklets` UI runtime. Call it once, early, before touching native +APIs. + +```ts +NativeScript.init(); // installs interop + worklet UI runtime +``` + +By default `init()` does **not** publish Objective‑C classes as globals on the +React Native JS thread — UIKit must be reached through worklets. Pass +`{ globals: true }` only if you deliberately want the JS‑thread globals. + +Install `react-native-worklets`, add both Babel plugins, and re‑run +`pod install` so `RNWorklets` is linked: ```sh npm install react-native-worklets ``` ```js +// babel.config.js module.exports = { presets: ["module:@react-native/babel-preset"], plugins: [ @@ -45,122 +63,71 @@ module.exports = { }; ``` -`installWorklets()` is still exported for custom initialization, but it throws -when Worklets is unavailable or incompatible. `runOnUI()` throws when the -callback was not transformed into a Worklets function. - -Obj-C blocks and JS-backed Obj-C method callbacks, including `NSObject.extend` -subclass overrides and delegates created with `createDelegate()`, should return -to React Native's JS thread for JS work. Use `jsInvoker()` when a callback can be -reached from a native caller thread: - -```ts -UIView.animateWithDurationAnimationsCompletion( - 0.25, - null, - NativeScript.jsInvoker((finished) => { - console.log("animation finished", finished); - }), -); -``` - -Delegate, data-source, target/action, and `UIAction` callbacks are JS-side -callbacks. Treat their bodies as JS work. If a callback can be reached from a -background native thread and needs to mutate UIKit, wrap the mutation in -`NativeScript.runOnUI()` with a Worklets callback. - -The package also includes a Babel plugin for directive-style JS callbacks: +The NativeScript Babel plugin rewrites directive‑style callbacks: a `"use js"` +callback becomes an interop callback that runs back on the React Native JS +thread. `"use ui"` is rejected in React Native — use a `"worklet"` callback with +`NativeScript.runOnUI()` instead. ```ts someNativeApi(() => { "use js"; - console.log("back on JS"); + console.log("back on the RN JS thread"); }); ``` -The transform rewrites those callbacks to `NativeScript.jsInvoker(fn)`. -`"use ui"` is rejected in React Native; use a Worklets `"worklet"` callback with -`NativeScript.runOnUI()` instead. +--- -## Defining native UIKit views in JS +## The threading model -Use `defineUIKitView()` to turn a NativeScript-created `UIView` tree into a -normal React Native component. The package owns the RN host view; your -definition owns the UIKit subtree. `create`, `update`, `mounted`, and `dispose` -run through the NativeScript UI dispatcher, so UIKit calls are safe and use the -same globals and iOS SDK types as NativeScript. +There are three execution contexts, and getting UIKit onto the right one is the +whole point of this package: -```tsx -import NativeScript, { defineUIKitView } from "@nativescript/react-native"; -import type { UIKitViewRef } from "@nativescript/react-native"; - -NativeScript.init(); - -type BadgeProps = { - title: string; - tone?: "blue" | "green"; -}; - -export const NativeBadge = defineUIKitView({ - name: "NativeBadge", - create() { - const view = UIView.alloc().initWithFrame(CGRectZero); - const label = UILabel.alloc().initWithFrame(CGRectZero); - label.tag = 1; - label.textAlignment = NSTextAlignment.Center; - label.textColor = UIColor.whiteColor; - label.autoresizingMask = - UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleHeight; - view.addSubview(label); - return view; - }, - update(view, props) { - view.backgroundColor = - props.tone === "green" - ? UIColor.systemGreenColor - : UIColor.systemBlueColor; - view.layer.cornerRadius = 12; - view.clipsToBounds = true; - const label = view.viewWithTag(1) as UILabel; - label.text = props.title; - }, -}); +| Context | What runs there | How you reach it | +| --- | --- | --- | +| **RN JS thread** | Your React render/effects, prop plumbing, `init()`. | Default. Never touch UIKit from here. | +| **Worklet UI runtime** | UIKit reads/writes, host `create`/`update`/… lifecycle, delegate/target‑action bodies. | `runOnUI()`, host lifecycle callbacks (already on the UI runtime). | +| **Main dispatch queue** | Work that must land on the platform main queue specifically. | `dispatchAsyncOnMainQueue()` (call from the UI runtime). | -; -``` +- **`runOnUI(callback, ...args)`** — schedules a `"worklet"` callback on the UI + runtime and resolves with its result. It only accepts a Worklets‑transformed + function; running the RN JS runtime as a UI‑thread shim is not supported, so a + non‑worklet callback throws. This is how you touch UIKit from React code. -Forward a ref when you need imperative access: + ```ts + const width = await NativeScript.runOnUI(() => { + "worklet"; + return UIScreen.mainScreen.bounds.size.width; + }); + ``` -```tsx -const badgeRef = useRef>(null); +- **`dispatchAsyncOnMainQueue(callback)`** — from inside a worklet, defers a + `() => void` onto the main dispatch queue (e.g. to let a presentation settle + before the next UIKit mutation). Returns `false` if the native scheduler is + not installed. -await badgeRef.current?.runOnUI((view) => { - "worklet"; - view.alpha = 0.8; -}); +- **`registerUIRuntimeGlobal(name, value)`** — installs a shared value as a + global on the UI runtime so multiple worklets can reach it without + re‑capturing it. Resolves to `true` once installed. Use it for cross‑worklet + singletons; prefer plain closure capture for one‑off values. -const measured = await badgeRef.current?.measureNative(); -badgeRef.current?.invalidateNativeLayout(); -``` +Host lifecycle callbacks (`create`, `update`, `mounted`, `dispose`, …) already +run on the UI runtime, so you do **not** wrap their bodies in `runOnUI()`. -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 `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. +--- -### Lifecycle and context +## Defining native hosts -`create`, `update`, `mounted`, and `dispose` run through the UIKit path. You do -not need to wrap UIKit work in `runOnUI()` inside those callbacks. +Three factories turn native objects into React components. Each takes a +definition whose lifecycle callbacks run on the UI runtime and receive a +context object (`ctx`). -The first argument to `create` is also the current props object, so existing -`create(props)` definitions keep working. New code can use the context helpers: +### `defineUIKitView` — one native `UIView` ```tsx -export const NativeSwitch = NativeScript.defineUIKitView< +import NativeScript, { defineUIKitView } from "@nativescript/react-native"; +import type { UIKitViewRef } from "@nativescript/react-native"; + +export const NativeSwitch = defineUIKitView< { value: boolean; onValueChange?: (value: boolean) => void }, UISwitch >({ @@ -174,128 +141,17 @@ export const NativeSwitch = NativeScript.defineUIKitView< return view; }, update(view, props) { - if (view.on !== props.value) { - view.setOnAnimated(props.value, false); - } + if (view.on !== props.value) view.setOnAnimated(props.value, false); }, }); -``` - -Context helpers cover common native view-manager patterns: - -- `ctx.emit(name, payload)` asynchronously calls the matching React prop. -- `ctx.targetAction(control, events, callback)` retains and removes a target/action helper. -- `ctx.delegate(object, protocol, implementation)` creates, assigns, and retains a delegate. -- `ctx.notification(name, object, callback)` observes and removes notifications. -- `ctx.observe(object, keyPath, callback)` observes and removes KVO. -- `ctx.retain(value)` keeps native helper objects alive for the component lifetime. -- `ctx.release(value)` releases a retained helper before component disposal. -- `ctx.dispose(callback)` runs cleanup once, in reverse registration order. -- `ctx.invalidateLayout()` schedules a fresh native measurement. - -### State, delegates, and retention -Native proxies support JavaScript expando properties for local state. Native -property setters still win first, and unsupported names fall back to JS state: - -```ts -NativeScript.runOnUI(() => { - "worklet"; - const view = UIView.new(); - view.ownerState = { selected: false }; - view.tag = 42; // still calls UIKit's native tag setter -}); +; ``` -Use `WeakMap`, React state, or another external object when you want state that -is not tied to the lifetime of a specific native proxy. +### `defineUIKitContainer` — a native `UIView` that hosts RN children -UIKit often retains delegates and actions weakly or outlives the JavaScript -closure that created them. Retain those helper objects explicitly. Use -`ctx.retain()` inside `defineUIKitView()`, or a standalone retainer elsewhere: - -```ts -const retainer = NativeScript.createRetainer(); - -const delegate = NativeScript.createDelegate( - UIScrollViewDelegate, - { - scrollViewDidScroll(scrollView) { - NativeScript.runOnUI(() => { - "worklet"; - scrollView.indicatorStyle = UIScrollViewIndicatorStyle.White; - }); - }, - }, - { retainer }, -); - -scrollView.delegate = delegate; - -// Later, when the owner is done: -scrollView.delegate = null; -retainer.dispose(); -``` - -`createDelegate(protocols, methods, options)` accepts protocol objects or names. -If metadata was generated before a framework was loaded, use strings with -`NativeScript.loadFramework()` and `NativeScript.getProtocol()`: - -```ts -NativeScript.loadFramework("QuickLook"); - -const dataSource = NativeScript.createDelegate( - "QLPreviewControllerDataSource", - { - numberOfPreviewItemsInPreviewController() { - return 1; - }, - previewControllerPreviewItemAtIndex() { - return NSURL.fileURLWithPath(path); - }, - }, - { owner: ctx }, -); -``` - -Use `NativeScript.retain(value)` and `NativeScript.release(value)` only for -process-lifetime helpers. Prefer `createRetainer()` or `ctx.retain()` for -component-scoped objects. - -### Layout - -React Native owns placement through Yoga. UIKit owns native behavior inside the -placed rectangle. Use `layout.sizing` to opt into native measurement: - -- `fill`: fill the RN host bounds. -- `intrinsic`: use `intrinsicContentSize`. -- `sizeThatFits`: use `sizeThatFits` with style constraints. -- `autoLayout`: use `systemLayoutSizeFittingSize`. - -Use `defaultSize`, `minSize`, and `maxSize` when a native view can report zero -or needs bounds during the first layout pass. - -```tsx -const NativeTitle = NativeScript.defineUIKitView<{ text: string }, UILabel>({ - name: "NativeTitle", - layout: { - sizing: "intrinsic", - defaultSize: { width: 1, height: 1 }, - }, - create() { - return UILabel.new(); - }, - update(label, props, _previous, ctx) { - label.text = props.text; - ctx?.invalidateLayout(); - }, -}); -``` - -### Containers and view controllers - -Use `defineUIKitContainer()` when React Native children should mount inside a -UIKit-owned content view: +`create` returns `{ rootView, childrenView }`; React Native children mount into +`childrenView`. ```tsx export const BlurCard = NativeScript.defineUIKitContainer({ @@ -304,20 +160,15 @@ export const BlurCard = NativeScript.defineUIKitContainer({ const rootView = UIVisualEffectView.alloc().initWithEffect( UIBlurEffect.effectWithStyle(UIBlurEffectStyle.SystemMaterial), ); - return { - rootView, - childrenView: rootView.contentView, - }; + return { rootView, childrenView: rootView.contentView }; }, }); - - - React Native child content -; ``` -Use `defineUIViewController()` for APIs that require real child view-controller -containment: +### `defineUIViewController` — real child‑controller containment + +Use this when UIKit expects a `UIViewController` (tabs, navigation, split views, +document browsers, presentations). `createController` returns the controller. ```tsx export const NativePageHost = NativeScript.defineUIViewController({ @@ -331,295 +182,207 @@ export const NativePageHost = NativeScript.defineUIViewController({ }); ``` -### Building app-specific native UI +### Lifecycle hooks -This package is intentionally low-level. It installs NativeScript's Native API -inside React Native and gives you lifecycle helpers; it does not ship opinionated -wrappers for tabs, maps, cameras, pickers, or other app components. Build those -as local components in your app or library: +Definitions may implement `create`/`createController`, `update`, `refresh`, +`mounted`, `dispose`, `hostReady`, `transactionCommitted`, +`mountingTransactionWillMount`/`DidMount`, `mountChild`/`unmountChild`, and +`nativeProps`. Each hook receives `(view, props, previousProps?, ctx?)` (details +vary per hook) and runs on the UI runtime. `dispose` may return +`{ removeHostView: true }` to also tear down the RN host view. -- Use `defineUIKitView()` for one native `UIView`. -- Use `defineUIKitContainer()` when React Native children should mount inside a - native `UIView`. -- Use `defineUIViewController()` when UIKit expects view-controller containment, - such as tabs, navigation controllers, split views, document browsers, preview - controllers, and presentation flows. -- Use `ctx.delegate()`, `ctx.targetAction()`, `ctx.retain()`, and - `ctx.dispose()` for native callbacks and weakly-held helper objects. -- Use `NativeScript.isClassAvailable()` before touching SDK-new APIs. +### The context (`ctx`) -For example, build native tabs with `UITabBarController` instead of measuring a -standalone `UITabBar` as a leaf RN view: +`ctx` is a [`UIKitViewContext`](src/index.ts) with the current `name`, `tag`, +`props`, Fabric handles, and helpers: -```tsx -type NativeTabsProps = { - selectedIndex: number; - onSelectedIndexChange?: (index: number) => void; -}; +- `ctx.emit(name, payload)` — asynchronously invoke the matching React prop. +- `ctx.targetAction(control, events, callback)` — retained target/action, auto‑removed on dispose. +- `ctx.gestureAction(gesture, callback)` — retained gesture target/action. +- `ctx.actionTarget(callback)` — a standalone retained target/action pair. +- `ctx.delegate(object, protocol, implementation)` — create + assign + retain a delegate. +- `ctx.notification(name, object, callback)` — observe + auto‑remove an `NSNotification`. +- `ctx.observe(object, keyPath, callback)` — add + auto‑remove a KVO observation. +- `ctx.retain(value)` / `ctx.release(value)` — keep native helpers alive for (or free them before) the component lifetime. +- `ctx.dispose(callback)` — register cleanup, run once in reverse order. +- `ctx.invalidateLayout()` — schedule a fresh native measurement. +- `ctx.loadImage(source, options, callback)` — resolve an RN image source to a native `UIImage`. -export const NativeTabs = NativeScript.defineUIViewController< - NativeTabsProps, - UITabBarController ->({ - name: "NativeTabs", - createController(ctx) { - const controller = UITabBarController.new(); - const viewControllers = TAB_ITEMS.map((item, index) => { - const child = UIViewController.new(); - child.view.backgroundColor = UIColor.systemBackgroundColor; - child.tabBarItem = UITabBarItem.alloc().initWithTitleImageSelectedImage( - item.title, - UIImage.systemImageNamed(item.symbol), - UIImage.systemImageNamed(item.selectedSymbol), - ); - child.tabBarItem.tag = index; - return child; - }); +Delegate, data‑source, target/action and `UIAction` callback bodies are JS work. +If one can be reached from a background native thread and needs to mutate UIKit, +wrap the mutation in `runOnUI()`. - controller.viewControllers = NSArray.arrayWithArray(viewControllers); - ctx.delegate(controller, UITabBarControllerDelegate, { - tabBarControllerDidSelectViewController(tabBarController) { - ctx.emit("onSelectedIndexChange", tabBarController.selectedIndex); - }, - }); - return controller; - }, - update(controller, props) { - controller.selectedIndex = props.selectedIndex; - }, -}); +### `layout.sizing` -; -``` +React Native owns placement through Yoga; UIKit owns native behavior inside the +placed rectangle. `layout.sizing` opts into native measurement: -For modal UIKit controllers, find the top visible presenter and guard against -double presentation: +- `fill` — fill the RN host bounds (default). +- `intrinsic` — use `intrinsicContentSize`. +- `sizeThatFits` — use `sizeThatFits` with the style constraints. +- `autoLayout` — use `systemLayoutSizeFittingSize`. -```ts -function topVisibleViewController( - root = UIApplication.sharedApplication.keyWindow?.rootViewController, -) { - let current = root; - while (current?.presentedViewController) { - current = current.presentedViewController; - } - if (current?.selectedViewController) { - return topVisibleViewController(current.selectedViewController); - } - if (current?.visibleViewController) { - return topVisibleViewController(current.visibleViewController); - } - return current; -} +Add `defaultSize`, `minSize`, `maxSize` when a native view can report zero or +needs bounds on the first pass. Call `ctx.invalidateLayout()` after content +changes. -await NativeScript.runOnUI(() => { +### Imperative refs + +```tsx +const ref = useRef>(null); +await ref.current?.runOnUI((view) => { "worklet"; - const presenter = topVisibleViewController(); - if (!presenter || presenter.presentedViewController) { - return; - } - presenter.presentViewControllerAnimatedCompletion(controller, true, null); + view.alpha = 0.8; }); +const size = await ref.current?.measureNative(); +ref.current?.invalidateNativeLayout(); ``` -### Availability and heavy UIKit classes - -Use availability helpers before touching optional frameworks. Simulator and -device availability can differ for frameworks such as VisionKit, QuickLook, and -PassKit. +--- + +## Host view props + +RN view props (`style`, `testID`, accessibility, responder props, +`pointerEvents`) go to the shared host component; your own props go to the +definition. Use `nativeProps(props)` in a definition when a plugin prop should +also affect the RN host. Beyond the RN props, [`UIKitHostViewProps`](src/index.ts) +exposes hosting‑strategy flags — most apps need none of them; adapters use them +to pick a containment model. One line each: + +- `adoptHostViewAsControllerView` — make the Fabric host view the controller's `view` (upstream RNS hosting; moves mounted children wholesale). +- `attachController` / `attachControllerToParent` / `detachControllerFromParent` — control whether/where the hosted controller is added as a child controller. +- `attachControllerView` / `attachNativeView` / `pinNativeViewToHost` — control how the native view is inserted and pinned into the host. +- `collectChildren` — expose mounted Fabric children for collection instead of mounting them (see `collectedUIKitHostChildren`). +- `mountChildrenDirectlyToChildrenView` / `layoutDirectChildrenToChildrenViewBounds` — mount/layout RN children straight into the children view. +- `disableDetachedChildrenTouchHandler` / `externalDetachedChildrenOwner` / `preserveDetachedChildrenLayout` — opt out of the detached‑children touch/layout plumbing when an upstream surface already owns it. +- `detachedChildrenContentOffsetX` / `detachedChildrenContentOffsetY` — offset detached hosted content. +- `disableUIKitHostWindowAttachRefresh` — skip the generic window‑attach refresh when native containment owns the hot path. +- `fabricLifecycleCallbacks` — enable the Fabric mount/commit lifecycle hooks. +- `immediateTransactionCommit` / `deferTransactionCommitOnRemovals` — tune when Fabric transactions are committed to the host. +- `emitOffWindowHostReady` / `ignoreHostReadyWindowAttachment` / `onHostReady` — control the `hostReady` lifecycle event and its window gating. + +--- + +## Interop utilities + +Each entry: signature — contract — the one hazard. + +- **`getClass(name): T | null`** — dynamic native class lookup. Returns `null` if unavailable. Hazard: globals are lazy; don't force member enumeration in hot paths. +- **`isClassAvailable(name): boolean`** — availability probe. Hazard: simulator vs device availability can differ for optional frameworks. +- **`loadFramework(nameOrPath): boolean`** — load a system framework by name or `.framework` path before using its classes/protocols. +- **`createDelegate(protocols, methods, options?): T`** — build + retain a protocol delegate from protocol objects or names. Hazard: UIKit holds delegates weakly — retain via `options.retainer`/`options.owner`, or it dies with the closure. +- **`nativeMethodPolicy(callback, policy)`** — tag a callback with a per‑method thread/return policy the bridge honors. Hazard: the marker is non‑enumerable; keep the tagged reference. +- **`nativeHandleForObject(value): string | undefined`** — stable string handle for a native object, safe to carry across worklets. +- **`nativeObjectFromHandle(handle): T | null`** — resolve a handle back to a native object on the UI runtime. Hazard: string handles round‑trip; numeric coercion is a lossy fallback. +- **`invokeObjCSelector(target, selector, args?): R`** — send an arbitrary Objective‑C selector; native object results are re‑wrapped. +- **`nativeArrayLength(value)` / `nativeArrayItem(value, index)`** — read a bridged `NSArray`/`NSOrderedSet` without assuming JS array shape. +- **`nativeSubviews(view): T[]`** — snapshot a `UIView`'s subviews on the UI runtime. +- **`loadImage(source, options, callback)`** — resolve an RN image source to a native `UIImage` (also available as `ctx.loadImage`). +- **`collectedUIKitHostChildren(view)` / `uikitHostHandlesForView(view)`** — read the Fabric children/handles a `collectChildren` host exposed. +- **`refreshUIKitHostView(view)` / `flushUIKitHostView(view)`** — re‑run a host's opt‑in `refresh`, or force its display to flush, when UIKit moved it without a React prop change. Both return `false` for non‑hosted views. +- **`notifyUIKitAccessibilityLayoutChanged(view)`** — post a UIKit accessibility layout‑changed notification for a reattached host. +- **`reactNativeFabricViewLayoutTraits(view)` / `…ForHandle(handle)`** — read a Fabric view's layout metrics/traits from an object or a handle. + +Objective‑C exceptions raised while dispatching through the bridge become JS +errors where they can be caught. Process‑level failures (`abort()`, fatal +assertions, memory corruption, some framework preconditions) are not catchable — +use availability checks and presentation guards instead of exceptions as control +flow. + +--- + +## The `__extendClass` contract + +`NSObject.extend(...)` (used under the hood by `createDelegate` and by direct +subclassing) builds a native subclass whose JS proxy forwards to the native +class. Two hazards ship with it: + +1. **Overriding an inherited property needs an accessor descriptor.** A plain + `value:` override on the extension object does not replace an inherited + Objective‑C property getter/setter — define the override with an accessor + (`get`/`set`) descriptor so the native property dispatch is actually + overridden. + +2. **`typeof proxy.sel === "function"` is not an availability check.** The proxy + answers `function` for selectors the class may respond to, so a truthy + `typeof` does not prove the selector is implemented/available. Use + `isClassAvailable()` / `respondsToSelector:` (or a real feature probe) before + relying on an optional selector. + +--- + +## Examples + +Runnable definitions ship under +[`@nativescript/react-native/examples`](examples): a switch, an intrinsic label, +a container, a view controller, a tab‑bar controller, a QuickLook preview +controller, and a presentation helper. + +--- + +## Install (bare React Native) -```ts -if ( - NativeScript.loadFramework("VisionKit") && - NativeScript.isClassAvailable("VNDocumentCameraViewController") -) { - const CameraController = NativeScript.getClass< - typeof VNDocumentCameraViewController - >("VNDocumentCameraViewController"); - const controller = CameraController?.new(); -} +```sh +npm install /path/to/nativescript-react-native-*.tgz react-native-worklets +cd ios +RCT_NEW_ARCH_ENABLED=1 USE_HERMES=1 pod install ``` -`NativeScript.isFrameworkLoaded(nameOrPath)` checks an `NSBundle`; -`NativeScript.loadFramework(nameOrPath)` loads a system framework by name or a -specific `.framework` path; `NativeScript.getClass(name)` and -`NativeScript.getProtocol(name)` return dynamically available native references. - -Class globals are lazy. Large UIKit classes such as `UITabBarController` can -have a wide inherited surface, so avoid forcing member enumeration with broad -reflection in hot paths. Constructing and direct property/method access stay -lazy; `Object.keys`, prototype introspection, and generated member lists are the -expensive path. - -Objective-C exceptions thrown while dispatching through the bridge are converted -to JS errors where Objective-C can catch them. Process-level failures such as -`abort()`, fatal assertions, memory corruption, and some framework precondition -violations are not catchable; use availability checks and presentation guards -instead of relying on exceptions as control flow. - -The package ships example definitions under `@nativescript/react-native/examples`. - -The published package includes generated NativeScript metadata, the libffi -xcframework, and generated iOS SDK TypeScript declarations. Build it from the -repository root with: +Add both Babel plugins (see above), then `init()` before using native APIs. A +small CLI is bundled for bare projects: ```sh -npm run build-rn-turbomodule +npx nativescript-rn configure # adds the Babel plugins + config, non-destructively +npx nativescript-rn generate-metadata --check ``` -The tarball is written to `packages/react-native/dist/` and copied to -`build/npm-tarballs/`. +## Install (Expo) -To verify it inside a generated React Native iOS app: +Expo Go can't load custom native code — use a development build, EAS Build, or +`npx expo run:ios`. ```sh -npm run test-rn-turbomodule +npx expo install @nativescript/react-native react-native-worklets ``` -## Using the package in a React Native app - -1. Build or download the package tarball. -2. Install it in an RN app that has Hermes and the New Architecture enabled: - - ```sh - npm install /path/to/nativescript-react-native-0.0.1.tgz react-native-worklets - cd ios - RCT_NEW_ARCH_ENABLED=1 USE_HERMES=1 pod install - ``` - -3. Initialize it before using native APIs: - - ```ts - import NativeScript from "@nativescript/react-native"; - - NativeScript.init(); - - await NativeScript.runOnUI(() => { - "worklet"; - UIApplication.sharedApplication.keyWindow.tintColor = - UIColor.systemPinkColor; - }); - ``` - -4. Add the bundled NativeScript Babel plugin and the Worklets Babel plugin: - - ```js - module.exports = { - presets: ["module:@react-native/babel-preset"], - plugins: [ - "@nativescript/react-native/babel-plugin", - "react-native-worklets/plugin", - ], - }; - ``` - -## Using the package in an Expo app - -Expo Go cannot load this package because it contains custom native code. Use an -Expo development build, EAS Build, or `npx expo run:ios`. - -1. Install the package: - - ```sh - npx expo install @nativescript/react-native react-native-worklets - ``` - - When testing a local tarball: - - ```sh - npm install /path/to/nativescript-react-native-0.0.1.tgz - ``` - -2. Add the config plugin to `app.json` or `app.config.js`: - - ```json - { - "expo": { - "plugins": ["@nativescript/react-native"] - } - } - ``` - - The plugin configures iOS for Hermes and the React Native New Architecture, - which are required by this JSI TurboModule. It also adds the - `@nativescript/react-native/babel-plugin` and `react-native-worklets/plugin` - transforms to `babel.config.js` so `"use js"` and worklet callbacks work in - Expo bundles. - -3. Prebuild and run the iOS development build: - - ```sh - npx expo prebuild --platform ios - npx expo run:ios - ``` - -4. Initialize NativeScript in app code before using native APIs: - - ```tsx - import NativeScript, { defineUIKitView } from "@nativescript/react-native"; - - NativeScript.init(); +```json +{ "expo": { "plugins": ["@nativescript/react-native"] } } +``` - const NativeBadge = defineUIKitView<{ title: string }, UIView>({ - name: "NativeBadge", - create() { - const view = UIView.alloc().initWithFrame(CGRectZero); - const label = UILabel.alloc().initWithFrame(CGRectZero); - label.tag = 1; - label.textAlignment = NSTextAlignment.Center; - view.addSubview(label); - return view; - }, - update(view, props) { - view.backgroundColor = UIColor.systemBlueColor; - const label = view.viewWithTag(1) as UILabel; - label.text = props.title; - }, - }); - ``` - -Set `{ "babelPlugin": false }` in the config plugin options if you prefer to add -the NativeScript and Worklets Babel plugins manually. - -The plugin also writes `nativescript.react-native.json` so metadata options are -visible to native builds. You can pass metadata inputs when the app uses -Objective-C-visible pods or extra system frameworks: +The config plugin enables Hermes + the New Architecture and adds both Babel +transforms. Pass metadata inputs when the app uses Objective‑C‑visible pods or +extra system frameworks: ```json { "expo": { "plugins": [ - [ - "@nativescript/react-native", - { - "metadata": { - "includePods": ["SomeObjCSDK"], - "includeSystemFrameworks": ["UIKit", "MapKit", "WebKit"] - } + ["@nativescript/react-native", { + "metadata": { + "includePods": ["SomeObjCSDK"], + "includeSystemFrameworks": ["UIKit", "MapKit", "WebKit"] } - ] + }] ] } } ``` -## Bare React Native setup helper +Set `{ "babelPlugin": false }` in the plugin options to add the Babel plugins +yourself. -The tarball includes a small CLI for bare RN projects: +--- + +## Building and testing the package ```sh -npx nativescript-rn configure -npx nativescript-rn generate-metadata --check -cd ios -RCT_NEW_ARCH_ENABLED=1 USE_HERMES=1 pod install +npm run build-rn-turbomodule # tarball -> packages/react-native/dist/ and build/npm-tarballs/ +npm run test-rn-turbomodule # verify inside a generated RN iOS app ``` -`configure` adds the bundled NativeScript and Worklets Babel plugins when -missing, writes -`nativescript.react-native.json`, and warns when the app is not configured for -Hermes and the New Architecture. The command is intentionally conservative and -does not make destructive native project edits. +The published tarball includes the generated NativeScript metadata, the libffi +xcframework, and the generated iOS SDK TypeScript declarations. `src/index.ts` +is the package's type surface (`package.json` "types"); it is authored for +babel/metro and carries `// @ts-nocheck`, so its exported declarations are the +checkable contract while its worklet‑host body stays out of consumers' strict +type checks. diff --git a/packages/react-native/ios/Fabric/NativeScriptUIViewComponentView.h b/packages/react-native/ios/Fabric/NativeScriptUIViewComponentView.h index c6567eb54..3a4965d68 100644 --- a/packages/react-native/ios/Fabric/NativeScriptUIViewComponentView.h +++ b/packages/react-native/ios/Fabric/NativeScriptUIViewComponentView.h @@ -1,4 +1,18 @@ #import @interface NativeScriptUIViewComponentView : RCTViewComponentView + +// YES while a Fabric mounting transaction is applying mutations to this view +// (between mountingTransactionWillMount and mountingTransactionDidMount). +// Hosts created lazily during a transaction must not replay a partial child +// snapshot as a transactionCommitted — didMount delivers the complete one. +@property(nonatomic, assign, readonly) BOOL isApplyingMountingTransaction; + ++ (nullable NativeScriptUIViewComponentView*)nativeScriptComponentViewForReactTag:(NSInteger)tag; + +- (NSDictionary*)applyNativeScriptUIKitHostProps: + (NSDictionary*)props; + +- (UIView*)nativeScriptCurrentContainerView; + @end diff --git a/packages/react-native/ios/Fabric/NativeScriptUIViewComponentView.mm b/packages/react-native/ios/Fabric/NativeScriptUIViewComponentView.mm index f94f048bb..14e9d3a79 100644 --- a/packages/react-native/ios/Fabric/NativeScriptUIViewComponentView.mm +++ b/packages/react-native/ios/Fabric/NativeScriptUIViewComponentView.mm @@ -2,12 +2,20 @@ #import #import +#import #import #import #import +#import "NativeScriptUIViewSizeFeedback.h" #import "NativeScriptUIView.h" +#if __has_include() +#import +#endif + +#include + using namespace facebook::react; static BOOL NativeScriptFabricViewIsDescendantOfView(UIView* view, UIView* ancestor) { @@ -21,6 +29,220 @@ static BOOL NativeScriptFabricViewIsDescendantOfView(UIView* view, UIView* ances return NO; } +static BOOL NativeScriptFabricLifecycleDebugEnabled() { + static BOOL enabled; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + const char* value = getenv("NS_NS_FABRIC_DEBUG"); + enabled = value != nullptr && value[0] != '\0' && strcmp(value, "0") != 0; + }); + return enabled; +} + +static NSString* NativeScriptFabricDescribeView(UIView* view) { + if (view == nil) { + return @""; + } + NSString* tagDescription = @""; + if ([view conformsToProtocol:@protocol(RCTComponentViewProtocol)]) { + tagDescription = [NSString stringWithFormat:@"; tag=%ld", + static_cast(((UIView*)view).tag)]; + } + + return [NSString stringWithFormat:@"<%@: %p%@; frame=%@; bounds=%@; hidden=%d; alpha=%.3f; window=%p; super=%@:%p; subviews=%lu>", + NSStringFromClass(view.class), + view, + tagDescription, + NSStringFromCGRect(view.frame), + NSStringFromCGRect(view.bounds), + view.hidden, + view.alpha, + view.window, + view.superview == nil ? @"nil" : NSStringFromClass(view.superview.class), + view.superview, + static_cast(view.subviews.count)]; +} + +static void NativeScriptFabricLifecycleLog(NSString* format, ...) { + if (!NativeScriptFabricLifecycleDebugEnabled()) { + return; + } + + va_list args; + va_start(args, format); + NSString* message = [[NSString alloc] initWithFormat:format arguments:args]; + va_end(args); + NSLog(@"[NS_NS_FABRIC_DEBUG] %@", message); + [message release]; +} + +static NSMapTable* +NativeScriptFabricComponentViewRegistry() { + static NSMapTable* registry; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + registry = [[NSMapTable strongToWeakObjectsMapTable] retain]; + }); + return registry; +} + +static NSString* NativeScriptFabricMutationTypeName( + facebook::react::ShadowViewMutation::Type type) { + switch (type) { + case facebook::react::ShadowViewMutation::Create: + return @"Create"; + case facebook::react::ShadowViewMutation::Delete: + return @"Delete"; + case facebook::react::ShadowViewMutation::Insert: + return @"Insert"; + case facebook::react::ShadowViewMutation::Remove: + return @"Remove"; + case facebook::react::ShadowViewMutation::Update: + return @"Update"; + } +} + +static void NativeScriptFabricLogMutationIfNativeScriptUIView( + const facebook::react::ShadowViewMutation& mutation) { + if (!NativeScriptFabricLifecycleDebugEnabled()) { + return; + } + const auto& newView = mutation.newChildShadowView; + const auto& oldView = mutation.oldChildShadowView; + const BOOL newIsNativeScript = + newView.componentName != nullptr && + std::strcmp(newView.componentName, "NativeScriptUIView") == 0; + const BOOL oldIsNativeScript = + oldView.componentName != nullptr && + std::strcmp(oldView.componentName, "NativeScriptUIView") == 0; + if (!newIsNativeScript && !oldIsNativeScript) { + return; + } + const auto& view = newIsNativeScript ? newView : oldView; + NativeScriptFabricLifecycleLog( + @"mutation type=%@ tag=%lld parent=%lld index=%d traits=%d layout=%g,%g %gx%g props=%d", + NativeScriptFabricMutationTypeName(mutation.type), + static_cast(view.tag), + static_cast(mutation.parentTag), + mutation.index, + static_cast(view.traits.get()), + view.layoutMetrics.frame.origin.x, + view.layoutMetrics.frame.origin.y, + view.layoutMetrics.frame.size.width, + view.layoutMetrics.frame.size.height, + view.props != nullptr); +} + +static NSString* NativeScriptFabricComponentName(const char* componentName) { + return componentName != nullptr ? [NSString stringWithUTF8String:componentName] : @""; +} + +static NSDictionary* NativeScriptFabricMutationRecord( + const facebook::react::ShadowViewMutation& mutation) { + const auto& newView = mutation.newChildShadowView; + const auto& oldView = mutation.oldChildShadowView; + return @{ + @"type" : NativeScriptFabricMutationTypeName(mutation.type), + @"parentTag" : @(mutation.parentTag), + @"index" : @(mutation.index), + @"newChildTag" : @(newView.tag), + @"newChildComponentName" : NativeScriptFabricComponentName(newView.componentName), + @"oldChildTag" : @(oldView.tag), + @"oldChildComponentName" : NativeScriptFabricComponentName(oldView.componentName), + }; +} + +static NSArray*>* NativeScriptFabricMutationRecords( + const facebook::react::MountingTransaction& transaction) { + NSMutableArray*>* records = [NSMutableArray array]; + for (const auto& mutation : transaction.getMutations()) { + [records addObject:NativeScriptFabricMutationRecord(mutation)]; + } + return records; +} + +static UIView* NativeScriptFabricCurrentContainerViewForComponentView(UIView* view) { + if (view == nil) { + return nil; + } + + SEL nativeScriptSelector = NSSelectorFromString(@"nativeScriptCurrentContainerView"); + if ([view respondsToSelector:nativeScriptSelector]) { + IMP implementation = [view methodForSelector:nativeScriptSelector]; + if (implementation != nullptr) { + UIView* (*nativeScriptCurrentContainerView)(id, SEL) = + reinterpret_cast(implementation); + UIView* containerView = nativeScriptCurrentContainerView(view, nativeScriptSelector); + if (containerView != nil) { + return containerView; + } + } + } + + SEL selector = NSSelectorFromString(@"currentContainerView"); + if (![view respondsToSelector:selector]) { + return view; + } + + IMP implementation = [view methodForSelector:selector]; + if (implementation == nullptr) { + return view; + } + + UIView* (*currentContainerView)(id, SEL) = + reinterpret_cast(implementation); + return currentContainerView(view, selector) ?: view; +} + +static BOOL NativeScriptFabricViewIsHostHitTestPlumbing(UIView* view) { + if (view == nil || [view isKindOfClass:UIControl.class]) { + return NO; + } + + NSString* className = NSStringFromClass(view.class); + const BOOL isNativeScriptHost = [className isEqualToString:@"NativeScriptUIView"] || + [className isEqualToString:@"NativeScriptUIViewComponentView"]; + +#if __has_include() + BOOL hasOnlySurfaceTouchHandlers = view.gestureRecognizers.count > 0; + for (UIGestureRecognizer* recognizer in view.gestureRecognizers) { + if (![recognizer isKindOfClass:RCTSurfaceTouchHandler.class]) { + hasOnlySurfaceTouchHandlers = NO; + break; + } + } +#else + BOOL hasOnlySurfaceTouchHandlers = NO; +#endif + + const BOOL isPlainSurfaceHost = + [className isEqualToString:@"UIView"] && + (view.gestureRecognizers.count == 0 || hasOnlySurfaceTouchHandlers) && + view.subviews.count > 0; + + if (!isNativeScriptHost && !isPlainSurfaceHost) { + return NO; + } + + return view.gestureRecognizers.count == 0 || hasOnlySurfaceTouchHandlers; +} + +static BOOL NativeScriptFabricColorIsEffectivelyClear(UIColor* color) { + if (color == nil) { + return YES; + } + + return CGColorGetAlpha(color.CGColor) <= 0.01; +} + +static BOOL NativeScriptFabricCGColorIsEffectivelyClear(CGColorRef color) { + if (color == nullptr) { + return YES; + } + + return CGColorGetAlpha(color) <= 0.01; +} + static CGRect NativeScriptFabricEffectiveTabBarHitBounds(UITabBar* tabBar) { CGRect bounds = tabBar.bounds; CGSize fittingSize = [tabBar sizeThatFits:CGSizeMake(bounds.size.width, bounds.size.height)]; @@ -34,6 +256,51 @@ static CGRect NativeScriptFabricEffectiveTabBarHitBounds(UITabBar* tabBar) { return CGRectInset(bounds, -24, -16); } +static CGRect NativeScriptFabricTabBarWindowHitFrame(UITabBar* tabBar, UIWindow* window) { + if (tabBar == nil) { + return CGRectNull; + } + + if (window != nil) { + return [tabBar convertRect:tabBar.bounds toView:window]; + } + + if (tabBar.superview != nil) { + return [tabBar.superview convertRect:tabBar.frame toView:nil]; + } + + return tabBar.frame; +} + +static CGRect NativeScriptFabricTabBarWindowHitBounds(UITabBar* tabBar, UIWindow* window) { + CGRect frame = NativeScriptFabricTabBarWindowHitFrame(tabBar, window); + if (CGRectIsNull(frame)) { + return frame; + } + + const CGFloat topEdge = window != nil ? window.safeAreaInsets.top + 20 : 64; + CGSize fittingSize = [tabBar sizeThatFits:CGSizeMake(frame.size.width, frame.size.height)]; + const CGFloat maximumHeight = MAX(fittingSize.height + 32, 96); + if (frame.size.height > maximumHeight) { + if (CGRectGetMinY(frame) <= topEdge) { + frame.size.height = maximumHeight; + } else { + frame.origin.y = CGRectGetMaxY(frame) - maximumHeight; + frame.size.height = maximumHeight; + } + } + + frame = CGRectInset(frame, -24, 0); + if (CGRectGetMinY(frame) <= topEdge) { + frame.origin.y -= 16; + frame.size.height += 16; + } else { + frame.origin.y -= 16; + frame.size.height += 32; + } + return frame; +} + static BOOL NativeScriptFabricPointInsideTabBarHitArea(UITabBar* tabBar, UIWindow* window, CGPoint windowPoint) { if (tabBar == nil || tabBar.hidden || tabBar.alpha <= 0.01 || @@ -41,16 +308,80 @@ static BOOL NativeScriptFabricPointInsideTabBarHitArea(UITabBar* tabBar, UIWindo return NO; } + CGRect frameHitBounds = NativeScriptFabricTabBarWindowHitBounds(tabBar, window); + if (!CGRectContainsPoint(frameHitBounds, windowPoint)) { + return NO; + } + CGPoint localPoint = [tabBar convertPoint:windowPoint fromView:window]; - return CGRectContainsPoint(NativeScriptFabricEffectiveTabBarHitBounds(tabBar), localPoint); + if (CGRectContainsPoint(NativeScriptFabricEffectiveTabBarHitBounds(tabBar), localPoint)) { + return YES; + } + + return YES; +} + +static UITabBar* NativeScriptFabricVisibleControllerTabBarAtPoint(UIViewController* controller, + UIWindow* window, + CGPoint windowPoint) { + if (controller == nil) { + return nil; + } + + UIViewController* presentedController = controller.presentedViewController; + if (presentedController != nil && !presentedController.isBeingDismissed) { + UITabBar* presentedTabBar = NativeScriptFabricVisibleControllerTabBarAtPoint( + presentedController, window, windowPoint); + if (presentedTabBar != nil) { + return presentedTabBar; + } + } + + NSArray* childControllers = controller.childViewControllers; + for (UIViewController* childController in [childControllers reverseObjectEnumerator]) { + UITabBar* childTabBar = + NativeScriptFabricVisibleControllerTabBarAtPoint(childController, window, windowPoint); + if (childTabBar != nil) { + return childTabBar; + } + } + + if ([controller isKindOfClass:UITabBarController.class]) { + UITabBarController* tabBarController = static_cast(controller); + UITabBar* tabBar = tabBarController.tabBar; + if (NativeScriptFabricPointInsideTabBarHitArea(tabBar, window, windowPoint)) { + return tabBar; + } + } + + return nil; +} + +static UITabBar* NativeScriptFabricVisibleWindowTabBarAtPoint(UIWindow* window, + CGPoint windowPoint) { + if (window == nil) { + return nil; + } + + UITabBar* controllerTabBar = NativeScriptFabricVisibleControllerTabBarAtPoint( + window.rootViewController, window, windowPoint); + if (controllerTabBar != nil) { + return controllerTabBar; + } + + return nil; } static UITabBar* NativeScriptFabricVisibleTabBarAtPoint(UIView* root, UIWindow* window, CGPoint windowPoint) { - if (root.hidden || root.alpha <= 0.01 || !root.userInteractionEnabled) { + if (root == nil) { return nil; } + if ([root isKindOfClass:UIWindow.class]) { + return NativeScriptFabricVisibleWindowTabBarAtPoint(static_cast(root), windowPoint); + } + if ([root isKindOfClass:UITabBar.class]) { UITabBar* tabBar = static_cast(root); if (NativeScriptFabricPointInsideTabBarHitArea(tabBar, window, windowPoint)) { @@ -68,12 +399,91 @@ static BOOL NativeScriptFabricPointInsideTabBarHitArea(UITabBar* tabBar, UIWindo return nil; } -@interface NativeScriptUIViewComponentView () +static UIView* NativeScriptFabricHitTestTabBarAtPoint(UIView* root, UIWindow* window, + CGPoint windowPoint, UIEvent* event) { + UITabBar* tabBar = NativeScriptFabricVisibleTabBarAtPoint(root, window, windowPoint); + if (tabBar == nil) { + return nil; + } + + CGPoint tabBarPoint = [tabBar convertPoint:windowPoint fromView:window]; + if (!CGRectContainsPoint(NativeScriptFabricEffectiveTabBarHitBounds(tabBar), tabBarPoint) && + CGRectContainsPoint(NativeScriptFabricTabBarWindowHitBounds(tabBar, window), windowPoint)) { + tabBarPoint = CGPointMake(windowPoint.x - tabBar.frame.origin.x, + windowPoint.y - tabBar.frame.origin.y); + } + UIView* tabBarHitView = [tabBar hitTest:tabBarPoint withEvent:event]; + if (tabBarHitView == tabBar && + CGRectContainsPoint(NativeScriptFabricTabBarWindowHitBounds(tabBar, window), windowPoint)) { + CGPoint fallbackPoint = CGPointMake(windowPoint.x - tabBar.frame.origin.x, + windowPoint.y - tabBar.frame.origin.y); + UIView* fallbackHitView = [tabBar hitTest:fallbackPoint withEvent:event]; + if (fallbackHitView != nil && fallbackHitView != tabBar) { + return fallbackHitView; + } + } + return tabBarHitView ?: tabBar; +} + +@interface NativeScriptUIViewComponentView () @end @implementation NativeScriptUIViewComponentView { NativeScriptUIView* _containerView; NSString* _debugName; + UIColor* _emptyHostWrapperSavedBackgroundColor; + UIColor* _emptyHostWrapperSavedContainerBackgroundColor; + CGColorRef _emptyHostWrapperSavedLayerBackgroundColor; + CGColorRef _emptyHostWrapperSavedContainerLayerBackgroundColor; + CGFloat _emptyHostWrapperSavedAlpha; + CGFloat _emptyHostWrapperSavedContainerAlpha; + BOOL _emptyHostWrapperSavedOpaque; + BOOL _emptyHostWrapperSavedContainerOpaque; + BOOL _emptyHostWrapperSavedLayerOpaque; + BOOL _emptyHostWrapperSavedContainerLayerOpaque; + BOOL _emptyHostWrapperVisualsSuppressed; + BOOL _hasModifiedChildrenInCurrentTransaction; + BOOL _hasModifiedPropsInCurrentTransaction; + BOOL _hasObservedPropsUpdateSinceLastTransaction; + BOOL _isApplyingMountingTransaction; + BOOL _hasPendingFabricTransactionCommitFallbackChildren; + BOOL _hasPendingFabricTransactionCommitFallbackProps; + NSInteger _registeredReactTag; + // SEAM D STAGE 0: the historical, independently-bumped + // _mountingTransactionToken and _fabricTransactionCommitFallbackToken have + // been deleted -- both deferred-delivery paths below now capture/check the + // single shared token owned by _containerView (see + // NativeScriptUIView.h's fabricTransactionDeliveryToken / + // advanceFabricTransactionDeliveryToken). + NSDictionary* _pendingHostReadyEvent; + facebook::react::NativeScriptUIViewSizedShadowNode::ConcreteState::Shared _sizeState; + CGSize _lastPushedAdoptedSize; + // RNS parity (RNSScreen's _newLayoutMetrics/_oldLayoutMetrics): cache the + // most recent layout metrics Fabric handed us, even on passes where we skip + // applying them to the view (adopted + under a navigation controller — see + // -updateLayoutMetrics:oldLayoutMetrics: below). Re-applied when adoption + // ends so the view immediately picks up its real Yoga-resolved frame. + facebook::react::LayoutMetrics _newLayoutMetrics, _oldLayoutMetrics; + BOOL _hasCachedLayoutMetrics; +} + ++ (NativeScriptUIViewComponentView*)nativeScriptComponentViewForReactTag:(NSInteger)tag { + // Lock hierarchy (see NativeScriptNativeApiModule.mm's + // nativeScriptApplyUIKitHostPropsForFabricTag / runUIKitHostFunction): no + // code that may run with the worklet runtime's runtimeMutex_ held (i.e. + // anything reachable from worklet JS) may block waiting on the main + // queue. An off-main caller here is a programming error -- matches the + // no-op-off-main pattern used by every other entry point in + // NativeScriptUIView.mm. + if (![NSThread isMainThread]) { + return nil; + } + + if (tag == 0) { + return nil; + } + return [NativeScriptFabricComponentViewRegistry() objectForKey:@(tag)]; } - (instancetype)initWithFrame:(CGRect)frame { @@ -83,17 +493,88 @@ - (instancetype)initWithFrame:(CGRect)frame { _containerView = [[NativeScriptUIView alloc] initWithFrame:self.bounds]; _containerView.hostReadyDelegate = self; + _containerView.fabricComponentView = self; _containerView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; self.contentView = _containerView; + NativeScriptFabricLifecycleLog(@"init ownerTag=%ld component=%@ container=%@", + static_cast(self.tag), + NativeScriptFabricDescribeView(self), + NativeScriptFabricDescribeView(_containerView)); } return self; } +- (void)nativeScriptRegisterCurrentReactTag { + if (![NSThread isMainThread]) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self nativeScriptRegisterCurrentReactTag]; + }); + return; + } + + const NSInteger tag = self.tag; + if (_registeredReactTag == tag) { + return; + } + + NSMapTable* registry = + NativeScriptFabricComponentViewRegistry(); + if (_registeredReactTag != 0) { + NativeScriptUIViewComponentView* registeredView = + [registry objectForKey:@(_registeredReactTag)]; + if (registeredView == self) { + [registry removeObjectForKey:@(_registeredReactTag)]; + } + } + + _registeredReactTag = tag; + if (tag != 0) { + [registry setObject:self forKey:@(tag)]; + } +} + +- (void)nativeScriptUnregisterCurrentReactTag { + if (![NSThread isMainThread]) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self nativeScriptUnregisterCurrentReactTag]; + }); + return; + } + + if (_registeredReactTag == 0) { + return; + } + + NSMapTable* registry = + NativeScriptFabricComponentViewRegistry(); + NativeScriptUIViewComponentView* registeredView = + [registry objectForKey:@(_registeredReactTag)]; + if (registeredView == self) { + [registry removeObjectForKey:@(_registeredReactTag)]; + } + _registeredReactTag = 0; +} + +- (void)setTag:(NSInteger)tag { + [super setTag:tag]; + [self nativeScriptRegisterCurrentReactTag]; +} + +- (UIView*)nativeScriptCurrentContainerView { + return _containerView ?: self; +} + - (void)dealloc { + [self nativeScriptUnregisterCurrentReactTag]; _containerView.hostReadyDelegate = nil; [_debugName release]; + [_pendingHostReadyEvent release]; + [_emptyHostWrapperSavedBackgroundColor release]; + [_emptyHostWrapperSavedContainerBackgroundColor release]; + CGColorRelease(_emptyHostWrapperSavedLayerBackgroundColor); + CGColorRelease(_emptyHostWrapperSavedContainerLayerBackgroundColor); [_containerView release]; [super dealloc]; } @@ -102,6 +583,18 @@ - (void)nativeScriptUIView:(NativeScriptUIView*)view didHostReady:(NSDictionary*)event { (void)view; if (_eventEmitter == nullptr) { + if (_pendingHostReadyEvent != event) { + [_pendingHostReadyEvent release]; + _pendingHostReadyEvent = [event copy]; + } + return; + } + + [self emitHostReadyEvent:event]; +} + +- (void)emitHostReadyEvent:(NSDictionary*)event { + if (_eventEmitter == nullptr || event == nil) { return; } @@ -109,13 +602,32 @@ - (void)nativeScriptUIView:(NativeScriptUIView*)view .onHostReady(NativeScriptUIViewEventEmitter::OnHostReady{ .hostReadyId = RCTStringFromNSString(event[@"hostReadyId"] ?: @""), .hostId = RCTStringFromNSString(event[@"hostId"] ?: @""), + .componentViewHandle = RCTStringFromNSString(event[@"componentViewHandle"] ?: @""), .nativeViewHandle = RCTStringFromNSString(event[@"nativeViewHandle"] ?: @""), .childrenViewHandle = RCTStringFromNSString(event[@"childrenViewHandle"] ?: @""), .controllerHandle = RCTStringFromNSString(event[@"controllerHandle"] ?: @""), .hasChildren = [event[@"hasChildren"] boolValue], + .visibleDescendantCount = [event[@"visibleDescendantCount"] intValue], + .windowAttached = [event[@"windowAttached"] boolValue], }); } +- (void)updateEventEmitter:(const EventEmitter::Shared&)eventEmitter { + [super updateEventEmitter:eventEmitter]; + + if (_eventEmitter == nullptr || _pendingHostReadyEvent == nil) { + return; + } + + NSDictionary* event = [_pendingHostReadyEvent retain]; + [_pendingHostReadyEvent release]; + _pendingHostReadyEvent = nil; + _sizeState = nullptr; + _lastPushedAdoptedSize = CGSizeZero; + [self emitHostReadyEvent:event]; + [event release]; +} + - (NSString*)description { if (_debugName.length == 0) { return [super description]; @@ -129,42 +641,657 @@ - (NSString*)description { return [description stringByAppendingFormat:@" debugName = %@", _debugName]; } +- (void)refreshContainerViewFrameIfNeeded { + // When the container view has been adopted as a UIViewController's view and + // that controller is pushed onto a UINavigationController, UIKit owns the + // frame (push/pop transitions, safe-area/nav-bar layout). Forcing our own + // frame here would fight UIKit's sizing, so defer to it in that case. + if ([_containerView shouldDeferContainerFrameToNavigationController]) { + return; + } + if (!CGRectEqualToRect(_containerView.frame, self.bounds)) { + _containerView.frame = self.bounds; + [_containerView setNeedsLayout]; + } +} + +- (void)storeEmptyHostWrapperVisualStateIfNeeded { + if (!_emptyHostWrapperVisualsSuppressed) { + _emptyHostWrapperSavedOpaque = self.opaque; + _emptyHostWrapperSavedContainerOpaque = _containerView.opaque; + _emptyHostWrapperSavedLayerOpaque = self.layer.opaque; + _emptyHostWrapperSavedContainerLayerOpaque = _containerView.layer.opaque; + _emptyHostWrapperSavedAlpha = self.alpha; + _emptyHostWrapperSavedContainerAlpha = _containerView.alpha; + } + + if (!_emptyHostWrapperVisualsSuppressed || + !NativeScriptFabricColorIsEffectivelyClear(self.backgroundColor)) { + [_emptyHostWrapperSavedBackgroundColor release]; + _emptyHostWrapperSavedBackgroundColor = [self.backgroundColor retain]; + } + + if (!_emptyHostWrapperVisualsSuppressed || + !NativeScriptFabricColorIsEffectivelyClear(_containerView.backgroundColor)) { + [_emptyHostWrapperSavedContainerBackgroundColor release]; + _emptyHostWrapperSavedContainerBackgroundColor = [_containerView.backgroundColor retain]; + } + + if (!_emptyHostWrapperVisualsSuppressed || + !NativeScriptFabricCGColorIsEffectivelyClear(self.layer.backgroundColor)) { + CGColorRelease(_emptyHostWrapperSavedLayerBackgroundColor); + _emptyHostWrapperSavedLayerBackgroundColor = + CGColorRetain(self.layer.backgroundColor); + } + + if (!_emptyHostWrapperVisualsSuppressed || + !NativeScriptFabricCGColorIsEffectivelyClear(_containerView.layer.backgroundColor)) { + CGColorRelease(_emptyHostWrapperSavedContainerLayerBackgroundColor); + _emptyHostWrapperSavedContainerLayerBackgroundColor = + CGColorRetain(_containerView.layer.backgroundColor); + } + + _emptyHostWrapperVisualsSuppressed = YES; +} + +- (void)restoreEmptyHostWrapperVisualStateIfNeeded { + if (!_emptyHostWrapperVisualsSuppressed) { + return; + } + + self.backgroundColor = _emptyHostWrapperSavedBackgroundColor; + _containerView.backgroundColor = _emptyHostWrapperSavedContainerBackgroundColor; + self.alpha = _emptyHostWrapperSavedAlpha; + _containerView.alpha = _emptyHostWrapperSavedContainerAlpha; + self.layer.backgroundColor = _emptyHostWrapperSavedLayerBackgroundColor; + _containerView.layer.backgroundColor = _emptyHostWrapperSavedContainerLayerBackgroundColor; + self.opaque = _emptyHostWrapperSavedOpaque; + _containerView.opaque = _emptyHostWrapperSavedContainerOpaque; + self.layer.opaque = _emptyHostWrapperSavedLayerOpaque; + _containerView.layer.opaque = _emptyHostWrapperSavedContainerLayerOpaque; + + [_emptyHostWrapperSavedBackgroundColor release]; + _emptyHostWrapperSavedBackgroundColor = nil; + [_emptyHostWrapperSavedContainerBackgroundColor release]; + _emptyHostWrapperSavedContainerBackgroundColor = nil; + CGColorRelease(_emptyHostWrapperSavedLayerBackgroundColor); + _emptyHostWrapperSavedLayerBackgroundColor = nullptr; + CGColorRelease(_emptyHostWrapperSavedContainerLayerBackgroundColor); + _emptyHostWrapperSavedContainerLayerBackgroundColor = nullptr; + _emptyHostWrapperVisualsSuppressed = NO; + [self.layer setNeedsDisplay]; + [_containerView.layer setNeedsDisplay]; +} + +- (void)refreshEmptyHostWrapperVisualState { + if (_containerView.hostId.length == 0) { + [self restoreEmptyHostWrapperVisualStateIfNeeded]; + return; + } + + if (![_containerView shouldHideEmptyFabricHostWrapper]) { + [self restoreEmptyHostWrapperVisualStateIfNeeded]; + return; + } + + [self storeEmptyHostWrapperVisualStateIfNeeded]; + self.backgroundColor = UIColor.clearColor; + _containerView.backgroundColor = UIColor.clearColor; + self.alpha = 0; + _containerView.alpha = 0; + self.layer.backgroundColor = UIColor.clearColor.CGColor; + _containerView.layer.backgroundColor = UIColor.clearColor.CGColor; + self.opaque = NO; + _containerView.opaque = NO; + self.layer.opaque = NO; + _containerView.layer.opaque = NO; + [self.layer setNeedsDisplay]; + [_containerView.layer setNeedsDisplay]; +} + +- (void)refreshContainerViewFrameAndHost { + [self refreshContainerViewFrameIfNeeded]; + [_containerView mountUIKitHostIfNeeded]; + // No unconditional setNeedsLayout here: this method runs from + // layoutSubviews, so re-invalidating every pass created a permanent + // 60/120 Hz layout->refresh loop per mounted host that saturated the main + // thread (mount transactions starved; presses appeared dead). + // refreshContainerViewFrameIfNeeded invalidates when the frame changed. + [_containerView refreshDetachedChildrenHost]; + [self refreshEmptyHostWrapperVisualState]; + self.hidden = NO; + const BOOL externallyOwned = _containerView.externalDetachedChildrenOwner; + self.accessibilityElementsHidden = externallyOwned; + _containerView.accessibilityElementsHidden = externallyOwned; +} + +- (void)scheduleFabricTransactionCommitFallbackIfNeeded { + if (!_containerView.fabricLifecycleCallbacks) { + return; + } + + // SEAM D STAGE 0: this fallback exists for the OUT-of-transaction path + // (worklet-driven nativeScriptApplyUIKitHostPropsForFabricTag / + // NativeScriptNativeApiModule.mm's runUIKitHostFunction, where + // mountingTransactionDidMount never fires). Inside a Fabric mounting + // transaction, mountingTransactionDidMount below is the legitimate + // initiator (RNS parity: RNSScreenStack.mm:1352-1370 delivers + // exactly-once, dispatch_async'd, ordered after layout) and always + // delivers once the transaction finishes -- scheduling here too just + // duplicates it one runloop turn later. This was producer #3 of the + // measured 4-6x per-pop transactionCommitted redelivery. + if (_isApplyingMountingTransaction) { + return; + } + + const BOOL hasModifiedChildren = _hasModifiedChildrenInCurrentTransaction; + const BOOL hasModifiedProps = _hasModifiedPropsInCurrentTransaction; + if (!hasModifiedChildren && !hasModifiedProps) { + return; + } + + _hasPendingFabricTransactionCommitFallbackChildren = + _hasPendingFabricTransactionCommitFallbackChildren || hasModifiedChildren; + _hasPendingFabricTransactionCommitFallbackProps = + _hasPendingFabricTransactionCommitFallbackProps || hasModifiedProps; + + const NSUInteger fallbackToken = [_containerView advanceFabricTransactionDeliveryToken]; + dispatch_async(dispatch_get_main_queue(), ^{ + if ([self->_containerView fabricTransactionDeliveryToken] != fallbackToken || + !self->_containerView.fabricLifecycleCallbacks) { + return; + } + + const BOOL hasModifiedChildren = + self->_hasModifiedChildrenInCurrentTransaction || + self->_hasPendingFabricTransactionCommitFallbackChildren; + const BOOL hasModifiedProps = + self->_hasModifiedPropsInCurrentTransaction || + self->_hasPendingFabricTransactionCommitFallbackProps; + if (!hasModifiedChildren && !hasModifiedProps) { + return; + } + + self->_hasModifiedChildrenInCurrentTransaction = NO; + self->_hasModifiedPropsInCurrentTransaction = NO; + self->_hasObservedPropsUpdateSinceLastTransaction = NO; + self->_hasPendingFabricTransactionCommitFallbackChildren = NO; + self->_hasPendingFabricTransactionCommitFallbackProps = NO; + + [self refreshContainerViewFrameAndHost]; + [self->_containerView + notifyFabricTransactionCommittedWithModifiedChildren:hasModifiedChildren + modifiedProps:hasModifiedProps]; + }); +} + +- (NSDictionary*)applyNativeScriptUIKitHostProps: + (NSDictionary*)props { + if (props == nil) { + return @{}; + } + + NSString* (^stringValue)(NSString*) = ^NSString*(NSString* key) { + id value = props[key]; + return [value isKindOfClass:NSString.class] ? static_cast(value) : nil; + }; + BOOL (^boolValue)(NSString*) = ^BOOL(NSString* key) { + id value = props[key]; + return [value respondsToSelector:@selector(boolValue)] ? [value boolValue] : NO; + }; + NSInteger (^integerValue)(NSString*) = ^NSInteger(NSString* key) { + id value = props[key]; + return [value respondsToSelector:@selector(integerValue)] ? [value integerValue] : 0; + }; + CGFloat (^floatValue)(NSString*) = ^CGFloat(NSString* key) { + id value = props[key]; + return [value respondsToSelector:@selector(doubleValue)] + ? static_cast([value doubleValue]) + : 0; + }; + + NSString* debugName = stringValue(@"debugName"); + if (!((_debugName == debugName) || [_debugName isEqualToString:debugName])) { + [_debugName release]; + _debugName = [debugName copy]; + } + _containerView.debugName = debugName; + + _containerView.attachNativeView = boolValue(@"attachNativeView"); + _containerView.attachControllerToParent = boolValue(@"attachControllerToParent"); + _containerView.adoptHostViewAsControllerView = + boolValue(@"adoptHostViewAsControllerView"); + _containerView.collectChildren = boolValue(@"collectChildren"); + _containerView.detachControllerFromParent = boolValue(@"detachControllerFromParent"); + _containerView.detachControllerView = boolValue(@"detachControllerView"); + _containerView.disableDetachedChildrenTouchHandler = + boolValue(@"disableDetachedChildrenTouchHandler"); + _containerView.disableUIKitHostWindowAttachRefresh = + boolValue(@"disableUIKitHostWindowAttachRefresh"); + _containerView.emitOffWindowHostReady = boolValue(@"emitOffWindowHostReady"); + _containerView.ignoreHostReadyWindowAttachment = + boolValue(@"ignoreHostReadyWindowAttachment"); + _containerView.externalDetachedChildrenOwner = boolValue(@"externalDetachedChildrenOwner"); + _containerView.fabricLifecycleCallbacks = boolValue(@"fabricLifecycleCallbacks"); + // Adopted screens (RNSScreen parity) require SYNCHRONOUS transaction commits: + // the fork's transactionCommitted reconcile finalizes the adoption + drives + // the size-feedback push, and if it is deferred a runloop turn (the default + // dispatch_async path) it races the layout/compositing pass and leaves the + // hosted ScrollView content mounted-but-not-composited. RNSScreen commits its + // state synchronously (unstable_Immediate); mirror that end-to-end here. + _containerView.immediateTransactionCommit = + boolValue(@"immediateTransactionCommit") || + boolValue(@"adoptHostViewAsControllerView"); + // RNS DidMount dispatch_async parity (adopted-only): when set, a mounting + // transaction that removes/deletes children is never committed + // synchronously here, even if immediateTransactionCommit is YES — see + // mountingTransactionDidMount below. + _containerView.deferTransactionCommitOnRemovals = + boolValue(@"deferTransactionCommitOnRemovals"); + _containerView.mountChildrenDirectlyToChildrenView = + boolValue(@"mountChildrenDirectlyToChildrenView"); + _containerView.layoutDirectChildrenToChildrenViewBounds = + boolValue(@"layoutDirectChildrenToChildrenViewBounds"); + _containerView.pinNativeViewToHost = boolValue(@"pinNativeViewToHost"); + _containerView.preserveDetachedChildrenLayout = boolValue(@"preserveDetachedChildrenLayout"); + _containerView.detachedChildrenContentOffsetX = + floatValue(@"detachedChildrenContentOffsetX"); + _containerView.detachedChildrenContentOffsetY = + floatValue(@"detachedChildrenContentOffsetY"); + + // Bug B fix: when the incoming JS handle string is missing or empty (""), + // JS state does not know the real native handle yet. Do NOT overwrite the + // live native handle with an empty string -- that drives + // setChildrenViewHandle:@"" -> setChildrenView(nil) and clears _childrenView, + // blanking the hosted React subtree (pushed Detail / presented Modal render + // blank). Skip the assignment and leave the existing native handle intact. + // Intentional clears are owned by -prepareForRecycle / -setHostId:'s reset, + // which assign nil directly on _containerView (not via this props path). + NSString* incomingNativeViewHandle = stringValue(@"nativeViewHandle"); + if (incomingNativeViewHandle.length > 0) { + _containerView.nativeViewHandle = incomingNativeViewHandle; + } + NSString* incomingChildrenViewHandle = stringValue(@"childrenViewHandle"); + if (incomingChildrenViewHandle.length > 0) { + _containerView.childrenViewHandle = incomingChildrenViewHandle; + } + NSString* incomingControllerHandle = stringValue(@"controllerHandle"); + if (incomingControllerHandle.length > 0) { + _containerView.controllerHandle = incomingControllerHandle; + } + + _hasModifiedPropsInCurrentTransaction = YES; + _hasObservedPropsUpdateSinceLastTransaction = YES; + _containerView.uikitHostPropsJson = stringValue(@"uikitHostPropsJson"); + _containerView.uikitHostPropsRevision = integerValue(@"uikitHostPropsRevision"); + _containerView.hostId = stringValue(@"hostId"); + _containerView.hostReadyId = stringValue(@"hostReadyId"); + _containerView.updateRevision = integerValue(@"updateRevision"); + _containerView.mountedRevision = integerValue(@"mountedRevision"); + + [self refreshContainerViewFrameAndHost]; + [self scheduleFabricTransactionCommitFallbackIfNeeded]; + + NativeScriptFabricLifecycleLog( + @"applyNativeScriptUIKitHostProps owner=%p debug=%@ hostId=%@ collect=%d lifecycle=%d mountedChildren=%lu handles=%@", + self, + _debugName ?: @"", + _containerView.hostId ?: @"", + _containerView.collectChildren, + _containerView.fabricLifecycleCallbacks, + static_cast(_containerView.fabricMountedChildrenSnapshot.count), + _containerView.uikitHostHandles); + + return [_containerView uikitHostHandles]; +} + - (void)mountChildComponentView:(UIView*)childComponentView index:(NSInteger)index { + NativeScriptFabricLifecycleLog(@"mountChild owner=%p ownerTag=%ld debug=%@ hostId=%@ index=%ld child=%@ childContainer=%@", + self, + static_cast(self.tag), + _debugName ?: @"", + _containerView.hostId ?: @"", + static_cast(index), + NativeScriptFabricDescribeView(childComponentView), + NativeScriptFabricDescribeView( + NativeScriptFabricCurrentContainerViewForComponentView( + childComponentView))); + _hasModifiedChildrenInCurrentTransaction = YES; [_containerView insertSubview:childComponentView atIndex:index]; - [_containerView refreshDetachedChildrenHost]; + [_containerView recordFabricChildComponentViewMounted:childComponentView index:index]; + if (_containerView.hostId.length == 0) { + return; + } + if (_containerView.fabricLifecycleCallbacks) { + [_containerView + notifyFabricChildMounted:childComponentView + childContainerView:NativeScriptFabricCurrentContainerViewForComponentView( + childComponentView) + index:index]; + } + [self refreshContainerViewFrameAndHost]; + [self scheduleFabricTransactionCommitFallbackIfNeeded]; } - (void)unmountChildComponentView:(UIView*)childComponentView index:(NSInteger)index { + NativeScriptFabricLifecycleLog(@"unmountChild owner=%p debug=%@ hostId=%@ index=%ld child=%@ childContainer=%@", + self, + _debugName ?: @"", + _containerView.hostId ?: @"", + static_cast(index), + NativeScriptFabricDescribeView(childComponentView), + NativeScriptFabricDescribeView( + NativeScriptFabricCurrentContainerViewForComponentView( + childComponentView))); + _hasModifiedChildrenInCurrentTransaction = YES; + [_containerView recordFabricChildComponentViewUnmounted:childComponentView]; + if (_containerView.hostId.length == 0) { + [childComponentView removeFromSuperview]; + // RNS parity: a Fabric-unmounted child must never be restored later by + // the reparenting guard (see restoreFabricChildComponentViewsForUnmount). + [_containerView + clearFabricRelocationRecordForUnmountedChildComponentView:childComponentView]; + return; + } + if (_containerView.fabricLifecycleCallbacks) { + [_containerView + notifyFabricChildUnmounted:childComponentView + childContainerView:NativeScriptFabricCurrentContainerViewForComponentView( + childComponentView) + index:index]; + } + [_containerView restoreFabricChildComponentViewsForUnmount:childComponentView index:index]; + if ([_containerView unmountCollectedChildComponentView:childComponentView]) { + [self refreshContainerViewFrameAndHost]; + [self scheduleFabricTransactionCommitFallbackIfNeeded]; + return; + } [childComponentView removeFromSuperview]; - [_containerView refreshDetachedChildrenHost]; + // RNS parity: a Fabric-unmounted child must never be restored later by the + // reparenting guard (see restoreFabricChildComponentViewsForUnmount). The + // collected-children and plain-view funnels already clear this record on + // their own final detach paths; this brings the direct paths to parity. + [_containerView + clearFabricRelocationRecordForUnmountedChildComponentView:childComponentView]; + [self refreshContainerViewFrameAndHost]; + [self scheduleFabricTransactionCommitFallbackIfNeeded]; +} + +- (void)mountingTransactionWillMount:(const facebook::react::MountingTransaction&)transaction + withSurfaceTelemetry:(const facebook::react::SurfaceTelemetry&)surfaceTelemetry { + (void)surfaceTelemetry; + static const BOOL profileHostCalls = getenv("NS_NS_HOST_PROFILE") != nullptr; + if (profileHostCalls) { + NSLog(@"NS_NS_HOST_PROFILE txn-will host=%@ mutations=%zu", + _containerView.hostId ?: @"?", transaction.getMutations().size()); + } + for (const auto& mutation : transaction.getMutations()) { + NativeScriptFabricLogMutationIfNativeScriptUIView(mutation); + } + _hasModifiedChildrenInCurrentTransaction = NO; + if (!_hasObservedPropsUpdateSinceLastTransaction) { + _hasModifiedPropsInCurrentTransaction = NO; + } + _isApplyingMountingTransaction = YES; + // Fabric delivers mounting-transaction callbacks to EVERY registered + // observer view for EVERY transaction in the app. Crossing into the UI + // worklet for hosts that have no mutation in this transaction made every + // unrelated Fabric commit (a text update, a counter) pay one worklet + // round-trip per live host. Upstream RNSScreenStackView scans the mutation + // list natively and reacts only to its own mutations; mirror that here. + BOOL transactionTouchesThisHost = NO; + // RNS `willBeUnmountedInUpcomingTransaction` parity: collect the tags of any + // children this transaction is Remove/Delete-ing from under us BEFORE any + // unmounts run, so restoreFabricChildComponentViewsForUnmount (which can be + // triggered by a SIBLING's unmount later in this same transaction) never + // resurrects a view Fabric is deleting here. + NSMutableSet* pendingUnmountTags = nil; + const auto selfTag = static_cast(self.tag); + for (const auto& mutation : transaction.getMutations()) { + if (mutation.parentTag == selfTag || + mutation.newChildShadowView.tag == selfTag || + mutation.oldChildShadowView.tag == selfTag) { + transactionTouchesThisHost = YES; + } + if (mutation.parentTag == selfTag && + (mutation.type == facebook::react::ShadowViewMutation::Remove || + mutation.type == facebook::react::ShadowViewMutation::Delete)) { + if (pendingUnmountTags == nil) { + pendingUnmountTags = [NSMutableSet new]; + } + [pendingUnmountTags addObject:@(mutation.oldChildShadowView.tag)]; + } + } + if (pendingUnmountTags != nil) { + [_containerView + markFabricChildComponentViewTagsPendingUnmountForCurrentTransaction:pendingUnmountTags]; + [pendingUnmountTags release]; + } + if (_containerView.fabricLifecycleCallbacks && transactionTouchesThisHost) { + [_containerView notifyFabricMountingTransactionWillMount]; + } +} + +- (BOOL)isApplyingMountingTransaction { + return _isApplyingMountingTransaction; +} + +- (void)mountingTransactionDidMount:(const facebook::react::MountingTransaction&)transaction + withSurfaceTelemetry:(const facebook::react::SurfaceTelemetry&)surfaceTelemetry { + (void)surfaceTelemetry; + static const BOOL profileHostCalls = getenv("NS_NS_HOST_PROFILE") != nullptr; + if (profileHostCalls) { + NSLog(@"NS_NS_HOST_PROFILE txn-did host=%@ children=%d props=%d immediate=%d", + _containerView.hostId ?: @"?", + _hasModifiedChildrenInCurrentTransaction ? 1 : 0, + (_hasModifiedPropsInCurrentTransaction || _hasPendingFabricTransactionCommitFallbackProps) ? 1 : 0, + _containerView.immediateTransactionCommit ? 1 : 0); + } + _isApplyingMountingTransaction = NO; + // RNS `willBeUnmountedInUpcomingTransaction` parity: the pending-unmount-tag + // set is transaction-scoped only; always clear it here regardless of + // whether this host observed modifications, mirroring how Fabric always + // pairs a mountingTransactionWillMount with a mountingTransactionDidMount. + [_containerView clearFabricChildComponentViewTagsPendingUnmountForCurrentTransaction]; + NSArray*>* mutationRecords = + NativeScriptFabricMutationRecords(transaction); + const BOOL hasModifiedChildren = + _hasModifiedChildrenInCurrentTransaction || + _hasPendingFabricTransactionCommitFallbackChildren; + const BOOL hasModifiedProps = + _hasModifiedPropsInCurrentTransaction || + _hasPendingFabricTransactionCommitFallbackProps; + _hasModifiedChildrenInCurrentTransaction = NO; + _hasModifiedPropsInCurrentTransaction = NO; + _hasObservedPropsUpdateSinceLastTransaction = NO; + + if (!hasModifiedChildren && !hasModifiedProps) { + return; + } + + _hasPendingFabricTransactionCommitFallbackChildren = NO; + _hasPendingFabricTransactionCommitFallbackProps = NO; + // SEAM D STAGE 0: advancing the shared token here immediately invalidates + // any still-pending deferred schedule from the mount-op fallback or the + // props-revision path (producers #2/#3) for this same host, and captures + // the fresh value this call's own (possibly deferred, below) delivery will + // check against. + const NSUInteger transactionToken = [_containerView advanceFabricTransactionDeliveryToken]; + + // RNS DidMount dispatch_async parity (adopted-only, gated by + // deferTransactionCommitOnRemovals): RNSScreenStackView never runs UIKit + // containment mutations synchronously inside a mounting transaction — see + // RNSScreenStack.mm mountingTransactionDidMount. Our transactionCommitted + // reconcile can dismiss/reparent views, so if this transaction contains any + // Remove/Delete, defer the reconcile out of the transaction even when + // immediateTransactionCommit is YES. Insert/update-only transactions (the + // render-fix path) are unaffected. + BOOL transactionHasRemovalMutation = NO; + if (_containerView.deferTransactionCommitOnRemovals) { + for (const auto& mutation : transaction.getMutations()) { + if (mutation.type == facebook::react::ShadowViewMutation::Remove || + mutation.type == facebook::react::ShadowViewMutation::Delete) { + transactionHasRemovalMutation = YES; + break; + } + } + } + + if (_containerView.immediateTransactionCommit && !transactionHasRemovalMutation) { + [self refreshContainerViewFrameAndHost]; + [_containerView + notifyFabricTransactionCommittedWithModifiedChildren:hasModifiedChildren + modifiedProps:hasModifiedProps + mutations:mutationRecords]; + return; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + if ([self->_containerView fabricTransactionDeliveryToken] != transactionToken) { + return; + } + + [self refreshContainerViewFrameAndHost]; + [self->_containerView + notifyFabricTransactionCommittedWithModifiedChildren:hasModifiedChildren + modifiedProps:hasModifiedProps + mutations:mutationRecords]; + }); } - (void)didMoveToWindow { [super didMoveToWindow]; - [_containerView refreshDetachedChildrenHost]; + NativeScriptFabricLifecycleLog(@"didMoveToWindow component=%@ container=%@ debug=%@ hostId=%@", + NativeScriptFabricDescribeView(self), + NativeScriptFabricDescribeView(_containerView), + _debugName ?: @"", + _containerView.hostId ?: @""); + [self refreshContainerViewFrameAndHost]; +} + +- (void)willMoveToSuperview:(UIView*)newSuperview { + [super willMoveToSuperview:newSuperview]; + NativeScriptFabricLifecycleLog(@"willMoveToSuperview component=%@ newSuperview=%@ debug=%@ hostId=%@", + NativeScriptFabricDescribeView(self), + NativeScriptFabricDescribeView(newSuperview), + _debugName ?: @"", + _containerView.hostId ?: @""); +} + +- (void)didMoveToSuperview { + [super didMoveToSuperview]; + NativeScriptFabricLifecycleLog(@"didMoveToSuperview component=%@ container=%@ debug=%@ hostId=%@", + NativeScriptFabricDescribeView(self), + NativeScriptFabricDescribeView(_containerView), + _debugName ?: @"", + _containerView.hostId ?: @""); } - (void)layoutSubviews { [super layoutSubviews]; - [_containerView refreshDetachedChildrenHost]; + [self refreshContainerViewFrameAndHost]; } - (void)updateLayoutMetrics:(const LayoutMetrics&)layoutMetrics oldLayoutMetrics:(const LayoutMetrics&)oldLayoutMetrics { - [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics]; - [_containerView refreshDetachedChildrenHost]; + // Always cache, even when we're about to skip applying these metrics below — + // they're re-applied once adoption ends (see updateProps's adoption-toggle-off + // site), mirroring RNSScreen's _newLayoutMetrics/_oldLayoutMetrics + + // -notifyWillAppear. + _newLayoutMetrics = layoutMetrics; + _oldLayoutMetrics = oldLayoutMetrics; + _hasCachedLayoutMetrics = YES; + // RNS parity (RNSScreen.mm updateLayoutMetrics ~1348-1371): once UIKit's + // navigation controller owns this adopted container's frame, do not let + // Yoga's resolved layout metrics drive the view's frame — UIKit is sizing + // it via push/pop transitions and safe-area/nav-bar layout, and applying our + // own frame here would fight that (RCTViewComponentView's default + // -updateLayoutMetrics:oldLayoutMetrics: sets self.frame from layoutMetrics). + if (![_containerView containerFrameIsUIKitDrivenByNavigationController]) { + [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics]; + } + NativeScriptFabricLifecycleLog(@"updateLayoutMetrics component=%@ container=%@ debug=%@ hostId=%@", + NativeScriptFabricDescribeView(self), + NativeScriptFabricDescribeView(_containerView), + _debugName ?: @"", + _containerView.hostId ?: @""); + // Unconditional: keeps the containerView's frame/host state in sync with + // whatever self.bounds ended up being, regardless of whether super applied + // the new layout metrics above. + [self refreshContainerViewFrameAndHost]; +} + +- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent*)event { + [self refreshContainerViewFrameIfNeeded]; + + if (_containerView.externalDetachedChildrenOwner) { + return NO; + } + + const BOOL superResult = [super pointInside:point withEvent:event]; + if (superResult && ![_containerView shouldHideEmptyFabricHostWrapper]) { + return YES; + } + + if (_containerView != nil && _containerView.window != nil) { + CGPoint containerPoint = [_containerView convertPoint:point fromView:self]; + if ([_containerView hostedContentPointInside:containerPoint withEvent:event]) { + return YES; + } + } + + if (self.window != nil) { + CGPoint windowPoint = [self convertPoint:point toView:self.window]; + UITabBar* tabBar = NativeScriptFabricVisibleTabBarAtPoint(self.window, self.window, windowPoint); + if (tabBar != nil && NativeScriptFabricViewIsDescendantOfView(tabBar, self)) { + return YES; + } + } + + return NO; } - (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent*)event { - [_containerView refreshDetachedChildrenHost]; + [self refreshContainerViewFrameIfNeeded]; + + if (_containerView.externalDetachedChildrenOwner) { + return nil; + } + + if (self.window != nil) { + CGPoint windowPoint = [self convertPoint:point toView:self.window]; + UIView* tabBarHitView = + NativeScriptFabricHitTestTabBarAtPoint(self.window, self.window, windowPoint, event); + if (tabBarHitView != nil) { + return tabBarHitView; + } + } + + if (_containerView != nil && _containerView.window != nil) { + CGPoint containerPoint = [_containerView convertPoint:point fromView:self]; + UIView* hostedHitView = [_containerView hostedContentHitTest:containerPoint withEvent:event]; + if (hostedHitView != nil) { + return hostedHitView; + } + } UIView* hitView = [super hitTest:point withEvent:event]; + if (hitView == self && + ([_containerView shouldHideEmptyFabricHostWrapper] || + NativeScriptFabricViewIsHostHitTestPlumbing(self))) { + hitView = nil; + } if (hitView == nil && _containerView != nil && _containerView.window != nil) { CGPoint containerPoint = [_containerView convertPoint:point fromView:self]; hitView = [_containerView hitTest:containerPoint withEvent:event]; } + if (hitView == self && + ([_containerView shouldHideEmptyFabricHostWrapper] || + NativeScriptFabricViewIsHostHitTestPlumbing(self))) { + hitView = nil; + } if (hitView == nil || self.window == nil) { return hitView; @@ -198,10 +1325,72 @@ - (void)updateProps:(Props::Shared const&)props oldProps:(Props::Shared const&)o const std::string newChildrenViewHandle = newViewProps->childrenViewHandle; const std::string oldControllerHandle = oldViewProps->controllerHandle; const std::string newControllerHandle = newViewProps->controllerHandle; + const auto oldAttachNativeView = oldViewProps->attachNativeView; + const auto newAttachNativeView = newViewProps->attachNativeView; + const auto oldAttachControllerToParent = oldViewProps->attachControllerToParent; + const auto newAttachControllerToParent = newViewProps->attachControllerToParent; + const auto oldAdoptHostViewAsControllerView = + oldViewProps->adoptHostViewAsControllerView; + const auto newAdoptHostViewAsControllerView = + newViewProps->adoptHostViewAsControllerView; + const auto oldCollectChildren = oldViewProps->collectChildren; + const auto newCollectChildren = newViewProps->collectChildren; + const auto oldDetachControllerFromParent = oldViewProps->detachControllerFromParent; + const auto newDetachControllerFromParent = newViewProps->detachControllerFromParent; const auto oldDetachControllerView = oldViewProps->detachControllerView; const auto newDetachControllerView = newViewProps->detachControllerView; + const auto oldDisableDetachedChildrenTouchHandler = + oldViewProps->disableDetachedChildrenTouchHandler; + const auto newDisableDetachedChildrenTouchHandler = + newViewProps->disableDetachedChildrenTouchHandler; + const auto oldDisableUIKitHostWindowAttachRefresh = + oldViewProps->disableUIKitHostWindowAttachRefresh; + const auto newDisableUIKitHostWindowAttachRefresh = + newViewProps->disableUIKitHostWindowAttachRefresh; + const auto oldEmitOffWindowHostReady = oldViewProps->emitOffWindowHostReady; + const auto newEmitOffWindowHostReady = newViewProps->emitOffWindowHostReady; + const auto oldIgnoreHostReadyWindowAttachment = + oldViewProps->ignoreHostReadyWindowAttachment; + const auto newIgnoreHostReadyWindowAttachment = + newViewProps->ignoreHostReadyWindowAttachment; + const auto oldExternalDetachedChildrenOwner = + oldViewProps->externalDetachedChildrenOwner; + const auto newExternalDetachedChildrenOwner = + newViewProps->externalDetachedChildrenOwner; + const auto oldFabricLifecycleCallbacks = oldViewProps->fabricLifecycleCallbacks; + const auto newFabricLifecycleCallbacks = newViewProps->fabricLifecycleCallbacks; + const auto oldImmediateTransactionCommit = oldViewProps->immediateTransactionCommit; + const auto newImmediateTransactionCommit = newViewProps->immediateTransactionCommit; + const auto oldDeferTransactionCommitOnRemovals = + oldViewProps->deferTransactionCommitOnRemovals; + const auto newDeferTransactionCommitOnRemovals = + newViewProps->deferTransactionCommitOnRemovals; + const auto oldMountChildrenDirectlyToChildrenView = + oldViewProps->mountChildrenDirectlyToChildrenView; + const auto newMountChildrenDirectlyToChildrenView = + newViewProps->mountChildrenDirectlyToChildrenView; + const auto oldLayoutDirectChildrenToChildrenViewBounds = + oldViewProps->layoutDirectChildrenToChildrenViewBounds; + const auto newLayoutDirectChildrenToChildrenViewBounds = + newViewProps->layoutDirectChildrenToChildrenViewBounds; + const auto oldPinNativeViewToHost = oldViewProps->pinNativeViewToHost; + const auto newPinNativeViewToHost = newViewProps->pinNativeViewToHost; + const auto oldPreserveDetachedChildrenLayout = oldViewProps->preserveDetachedChildrenLayout; + const auto newPreserveDetachedChildrenLayout = newViewProps->preserveDetachedChildrenLayout; + const auto oldDetachedChildrenContentOffsetX = + oldViewProps->detachedChildrenContentOffsetX; + const auto newDetachedChildrenContentOffsetX = + newViewProps->detachedChildrenContentOffsetX; + const auto oldDetachedChildrenContentOffsetY = + oldViewProps->detachedChildrenContentOffsetY; + const auto newDetachedChildrenContentOffsetY = + newViewProps->detachedChildrenContentOffsetY; const std::string oldDebugName = oldViewProps->debugName; const std::string newDebugName = newViewProps->debugName; + const std::string oldUIKitHostPropsJson = oldViewProps->uikitHostPropsJson; + const std::string newUIKitHostPropsJson = newViewProps->uikitHostPropsJson; + const auto oldUIKitHostPropsRevision = oldViewProps->uikitHostPropsRevision; + const auto newUIKitHostPropsRevision = newViewProps->uikitHostPropsRevision; const std::string oldHostId = oldViewProps->hostId; const std::string newHostId = newViewProps->hostId; const std::string oldHostReadyId = oldViewProps->hostReadyId; @@ -221,30 +1410,167 @@ - (void)updateProps:(Props::Shared const&)props oldProps:(Props::Shared const&)o _containerView.debugName = debugName; } + NativeScriptFabricLifecycleLog( + @"updateProps owner=%p ownerTag=%ld debug=%@ hostId=%s native=%s children=%s controller=%s collect=%d attachController=%d attachNative=%d externalOwner=%d mountChildrenDirect=%d layoutDirectChildren=%d immediate=%d uikitRev=%lld updateRev=%lld mountedRev=%lld", + self, + static_cast(self.tag), + _debugName ?: @"", + newHostId.c_str(), + newNativeViewHandle.c_str(), + newChildrenViewHandle.c_str(), + newControllerHandle.c_str(), + newCollectChildren, + newAttachControllerToParent, + newAttachNativeView, + newExternalDetachedChildrenOwner, + newMountChildrenDirectlyToChildrenView, + newLayoutDirectChildrenToChildrenViewBounds, + newImmediateTransactionCommit, + static_cast(newUIKitHostPropsRevision), + static_cast(newUpdateRevision), + static_cast(newMountedRevision)); + + if (oldAdoptHostViewAsControllerView != newAdoptHostViewAsControllerView) { + _containerView.adoptHostViewAsControllerView = newAdoptHostViewAsControllerView; + if (oldAdoptHostViewAsControllerView && !newAdoptHostViewAsControllerView && + _hasCachedLayoutMetrics) { + // RNS parity (RNSScreen.mm:512 -notifyWillAppear re-applies + // _newLayoutMetrics/_oldLayoutMetrics). Adoption just ended, so Fabric is + // about to resume owning this view's frame; -updateLayoutMetrics: was + // skipping applying metrics to the view while adopted (see above), so + // replay the last metrics now instead of waiting for an unrelated future + // layout pass to happen to re-deliver them. + [self updateLayoutMetrics:_newLayoutMetrics oldLayoutMetrics:_oldLayoutMetrics]; + } + } + + if (oldAttachNativeView != newAttachNativeView) { + _containerView.attachNativeView = newAttachNativeView; + } + + if (oldAttachControllerToParent != newAttachControllerToParent) { + _containerView.attachControllerToParent = newAttachControllerToParent; + } + + if (oldCollectChildren != newCollectChildren) { + _containerView.collectChildren = newCollectChildren; + } + + if (oldDetachControllerFromParent != newDetachControllerFromParent) { + _containerView.detachControllerFromParent = newDetachControllerFromParent; + } + if (oldDetachControllerView != newDetachControllerView) { _containerView.detachControllerView = newDetachControllerView; } - if (oldNativeViewHandle != newNativeViewHandle) { - NSString* nativeViewHandle = newNativeViewHandle.empty() - ? nil - : [NSString stringWithUTF8String:newNativeViewHandle.c_str()]; - _containerView.nativeViewHandle = nativeViewHandle; + if (oldDisableDetachedChildrenTouchHandler != newDisableDetachedChildrenTouchHandler) { + _containerView.disableDetachedChildrenTouchHandler = + newDisableDetachedChildrenTouchHandler; + } + + if (oldDisableUIKitHostWindowAttachRefresh != + newDisableUIKitHostWindowAttachRefresh) { + _containerView.disableUIKitHostWindowAttachRefresh = + newDisableUIKitHostWindowAttachRefresh; + } + + if (oldEmitOffWindowHostReady != newEmitOffWindowHostReady) { + _containerView.emitOffWindowHostReady = newEmitOffWindowHostReady; + } + + if (oldIgnoreHostReadyWindowAttachment != + newIgnoreHostReadyWindowAttachment) { + _containerView.ignoreHostReadyWindowAttachment = + newIgnoreHostReadyWindowAttachment; + } + + if (oldExternalDetachedChildrenOwner != newExternalDetachedChildrenOwner) { + _containerView.externalDetachedChildrenOwner = newExternalDetachedChildrenOwner; + } + + if (oldFabricLifecycleCallbacks != newFabricLifecycleCallbacks) { + _containerView.fabricLifecycleCallbacks = newFabricLifecycleCallbacks; + } + + // Adopted screens force synchronous transaction commits (RNSScreen parity; + // see the dictionary-prop path above for the full rationale) — fold + // adoption into the effective value here too, so the typed-props path + // matches regardless of whether immediateTransactionCommit was explicitly + // set at the JSX level. + const auto oldEffectiveImmediateTransactionCommit = + oldImmediateTransactionCommit || oldAdoptHostViewAsControllerView; + const auto newEffectiveImmediateTransactionCommit = + newImmediateTransactionCommit || newAdoptHostViewAsControllerView; + if (oldEffectiveImmediateTransactionCommit != newEffectiveImmediateTransactionCommit) { + _containerView.immediateTransactionCommit = newEffectiveImmediateTransactionCommit; + } + + if (oldDeferTransactionCommitOnRemovals != newDeferTransactionCommitOnRemovals) { + _containerView.deferTransactionCommitOnRemovals = newDeferTransactionCommitOnRemovals; + } + + if (oldMountChildrenDirectlyToChildrenView != + newMountChildrenDirectlyToChildrenView) { + _containerView.mountChildrenDirectlyToChildrenView = newMountChildrenDirectlyToChildrenView; + } + + if (oldLayoutDirectChildrenToChildrenViewBounds != + newLayoutDirectChildrenToChildrenViewBounds) { + _containerView.layoutDirectChildrenToChildrenViewBounds = + newLayoutDirectChildrenToChildrenViewBounds; + } + + if (oldPinNativeViewToHost != newPinNativeViewToHost) { + _containerView.pinNativeViewToHost = newPinNativeViewToHost; + } + + if (oldPreserveDetachedChildrenLayout != newPreserveDetachedChildrenLayout) { + _containerView.preserveDetachedChildrenLayout = newPreserveDetachedChildrenLayout; + } + + if (oldDetachedChildrenContentOffsetX != newDetachedChildrenContentOffsetX) { + _containerView.detachedChildrenContentOffsetX = newDetachedChildrenContentOffsetX; + } + + if (oldDetachedChildrenContentOffsetY != newDetachedChildrenContentOffsetY) { + _containerView.detachedChildrenContentOffsetY = newDetachedChildrenContentOffsetY; + } + + // Bug B fix: a stale-empty ("") incoming handle means JS does not know the + // real native handle yet -- skip the assignment rather than clobbering the + // live native handle with nil (which would clear _childrenView and blank the + // hosted React subtree). Intentional clears run through -prepareForRecycle / + // -setHostId:, not this typed change-path. + if (oldNativeViewHandle != newNativeViewHandle && !newNativeViewHandle.empty()) { + _containerView.nativeViewHandle = + [NSString stringWithUTF8String:newNativeViewHandle.c_str()]; } - if (oldChildrenViewHandle != newChildrenViewHandle) { - NSString* childrenViewHandle = - newChildrenViewHandle.empty() + if (oldChildrenViewHandle != newChildrenViewHandle && !newChildrenViewHandle.empty()) { + _containerView.childrenViewHandle = + [NSString stringWithUTF8String:newChildrenViewHandle.c_str()]; + } + + if (oldControllerHandle != newControllerHandle && !newControllerHandle.empty()) { + _containerView.controllerHandle = + [NSString stringWithUTF8String:newControllerHandle.c_str()]; + } + + if (oldUIKitHostPropsJson != newUIKitHostPropsJson) { + _hasModifiedPropsInCurrentTransaction = YES; + _hasObservedPropsUpdateSinceLastTransaction = YES; + NSString* uikitHostPropsJson = + newUIKitHostPropsJson.empty() ? nil - : [NSString stringWithUTF8String:newChildrenViewHandle.c_str()]; - _containerView.childrenViewHandle = childrenViewHandle; + : [NSString stringWithUTF8String:newUIKitHostPropsJson.c_str()]; + _containerView.uikitHostPropsJson = uikitHostPropsJson; } - if (oldControllerHandle != newControllerHandle) { - NSString* controllerHandle = newControllerHandle.empty() - ? nil - : [NSString stringWithUTF8String:newControllerHandle.c_str()]; - _containerView.controllerHandle = controllerHandle; + if (oldUIKitHostPropsRevision != newUIKitHostPropsRevision) { + _hasModifiedPropsInCurrentTransaction = YES; + _hasObservedPropsUpdateSinceLastTransaction = YES; + _containerView.uikitHostPropsRevision = newUIKitHostPropsRevision; } if (oldHostId != newHostId) { @@ -260,6 +1586,8 @@ - (void)updateProps:(Props::Shared const&)props oldProps:(Props::Shared const&)o } if (oldUpdateRevision != newUpdateRevision) { + _hasModifiedPropsInCurrentTransaction = YES; + _hasObservedPropsUpdateSinceLastTransaction = YES; _containerView.updateRevision = newUpdateRevision; } @@ -267,26 +1595,169 @@ - (void)updateProps:(Props::Shared const&)props oldProps:(Props::Shared const&)o _containerView.mountedRevision = newMountedRevision; } - [_containerView refreshDetachedChildrenHost]; + [self refreshContainerViewFrameAndHost]; + [self scheduleFabricTransactionCommitFallbackIfNeeded]; +} + ++ (BOOL)shouldBeRecycled { + return NO; } - (void)prepareForRecycle { + NativeScriptFabricLifecycleLog(@"prepareForRecycle component=%@ container=%@ debug=%@ hostId=%@", + NativeScriptFabricDescribeView(self), + NativeScriptFabricDescribeView(_containerView), + _debugName ?: @"", + _containerView.hostId ?: @""); + [self nativeScriptUnregisterCurrentReactTag]; + [self restoreEmptyHostWrapperVisualStateIfNeeded]; + [_containerView restoreFabricChildComponentViewsForUnmount:nil index:NSNotFound]; [super prepareForRecycle]; [_debugName release]; _debugName = nil; + [_pendingHostReadyEvent release]; + _pendingHostReadyEvent = nil; _containerView.hostId = nil; _containerView.hostReadyId = nil; + [_containerView clearFabricChildComponentViewRecords]; _containerView.debugName = nil; _containerView.nativeViewHandle = nil; _containerView.childrenViewHandle = nil; + // Turn adoption off BEFORE dropping the controller handle so the restore + // (controller gets a plain replacement view, retain cycle broken) runs + // while the controller is still known; then reclaim the container into the + // recycled shell if UIKit containment moved it. + _containerView.adoptHostViewAsControllerView = NO; _containerView.controllerHandle = nil; + if (_containerView.superview != self) { + [_containerView removeFromSuperview]; + [self addSubview:_containerView]; + } + _containerView.attachNativeView = NO; + _containerView.attachControllerToParent = NO; + _containerView.collectChildren = NO; + _containerView.detachControllerFromParent = NO; _containerView.detachControllerView = NO; + _containerView.disableDetachedChildrenTouchHandler = NO; + _containerView.disableUIKitHostWindowAttachRefresh = NO; + _containerView.emitOffWindowHostReady = NO; + _containerView.ignoreHostReadyWindowAttachment = NO; + _containerView.externalDetachedChildrenOwner = NO; + _containerView.fabricLifecycleCallbacks = NO; + _containerView.immediateTransactionCommit = NO; + _containerView.deferTransactionCommitOnRemovals = NO; + _containerView.mountChildrenDirectlyToChildrenView = NO; + _containerView.layoutDirectChildrenToChildrenViewBounds = NO; + _containerView.pinNativeViewToHost = NO; + _containerView.preserveDetachedChildrenLayout = NO; + _containerView.detachedChildrenContentOffsetX = 0; + _containerView.detachedChildrenContentOffsetY = 0; + _containerView.uikitHostPropsJson = nil; + _containerView.uikitHostPropsRevision = 0; _containerView.updateRevision = 0; _containerView.mountedRevision = 0; + self.hidden = NO; + _hasModifiedChildrenInCurrentTransaction = NO; + _hasModifiedPropsInCurrentTransaction = NO; + _hasObservedPropsUpdateSinceLastTransaction = NO; + _hasPendingFabricTransactionCommitFallbackChildren = NO; + _hasPendingFabricTransactionCommitFallbackProps = NO; + // SEAM D STAGE 0: invalidate any still-pending deferred delivery (from + // either producer) against the shared token before this container/instance + // is torn down/reset. + [_containerView advanceFabricTransactionDeliveryToken]; + _hasCachedLayoutMetrics = NO; +} + +// RNS parity (RNSScreen.mm -invalidate / -invalidateImpl): `+shouldBeRecycled` +// returns NO for this class, so Fabric NEVER moves instances into the recycle +// pool — RCTComponentViewRegistry's _enqueueComponentViewWithComponentHandle: +// calls -invalidate (not -prepareForRecycle) for every permanent unmount of +// this component. Before this override, that path fell through to the +// no-op base implementation, so none of the hostId/relocation-record/ +// adoption teardown below ever ran on permanent discard — a per-modal +// controller+container leak. Do the safe, non-UIKit-containment-mutating +// teardown synchronously, then defer the adoption unwind (which mutates +// UIKit containment via restoreAdoptedControllerViewIfNeeded) so it never +// races an in-flight transition/mounting transaction, mirroring RNS +// deferring `_controller = nil` in -invalidateImpl. +- (void)invalidate { + NativeScriptFabricLifecycleLog(@"invalidate component=%@ container=%@ debug=%@ hostId=%@", + NativeScriptFabricDescribeView(self), + NativeScriptFabricDescribeView(_containerView), + _debugName ?: @"", + _containerView.hostId ?: @""); + [self nativeScriptUnregisterCurrentReactTag]; + [_containerView restoreFabricChildComponentViewsForUnmount:nil index:NSNotFound]; + [_containerView clearFabricChildComponentViewRecords]; + _containerView.hostId = nil; + _containerView.hostReadyId = nil; + [_pendingHostReadyEvent release]; + _pendingHostReadyEvent = nil; + + // Legacy (never-adopted) hosts have nothing to unwind — near-no-op. + if (_containerView.adoptHostViewAsControllerView) { + // MRC (this file is built with -fno-objc-arc): retain the container for + // the lifetime of the deferred block instead of an ARC-style weak + // capture, so it is safe to touch even if this component view itself has + // already been deallocated by the time the block runs. + NativeScriptUIView* retainedContainerView = [_containerView retain]; + dispatch_async(dispatch_get_main_queue(), ^{ + // Turn adoption off BEFORE dropping the controller handle (same + // ordering rationale as -prepareForRecycle above): the restore + // (controller gets a plain replacement view, retain cycle broken) + // must run while the controller is still known. + retainedContainerView.adoptHostViewAsControllerView = NO; + retainedContainerView.controllerHandle = nil; + [retainedContainerView release]; + }); + } + + [super invalidate]; +} + +- (void)updateState:(const facebook::react::State::Shared&)state + oldState:(const facebook::react::State::Shared&)oldState { + _sizeState = std::static_pointer_cast< + const facebook::react::NativeScriptUIViewSizedShadowNode::ConcreteState>( + state); +} + +// Push the UIKit-resolved adopted-container size into the shadow tree so the +// custom descriptor's adopt() re-sizes the Yoga node and the hosted subtree +// re-lays-out at the real dimensions. Only meaningful while adopted and +// UIKit-owned; a zero/again-equal size is skipped to avoid redundant commits. +- (void)pushAdoptedContainerSizeToShadowTree:(CGSize)size { + if (_sizeState == nullptr) { + return; + } + if (size.width <= 0 || size.height <= 0) { + return; + } + if (CGSizeEqualToSize(size, _lastPushedAdoptedSize)) { + return; + } + _lastPushedAdoptedSize = size; + // Commit SYNCHRONOUSLY (unstable_Immediate), exactly like RNSScreen's + // -updateBounds. An Asynchronous state update is processed on a later React + // commit that races UIKit's mount/compositing pass, which leaves the hosted + // subtree (esp. a ScrollView's below-the-fold content) laid out in the shadow + // tree but not composited until some other event (e.g. a user scroll) forces a + // new commit. A synchronous commit re-lays-out and re-mounts the hosted + // content within this same layout pass, so it is composited immediately. This + // is called from -layoutSubviews on the main thread, where an immediate commit + // is safe. + _sizeState->updateState( + facebook::react::NativeScriptUIViewSizeStateData{ + facebook::react::Size{ + static_cast(size.width), + static_cast(size.height)}}, + facebook::react::EventQueue::UpdateMode::unstable_Immediate); } + (ComponentDescriptorProvider)componentDescriptorProvider { - return concreteComponentDescriptorProvider(); + // Custom descriptor with UIKit->shadow-tree size feedback (adoption). + return concreteComponentDescriptorProvider(); } @end diff --git a/packages/react-native/ios/Fabric/NativeScriptUIViewSizeFeedback.h b/packages/react-native/ios/Fabric/NativeScriptUIViewSizeFeedback.h new file mode 100644 index 000000000..b4c910820 --- /dev/null +++ b/packages/react-native/ios/Fabric/NativeScriptUIViewSizeFeedback.h @@ -0,0 +1,82 @@ +#pragma once + +// UIKit -> shadow-tree size feedback for the NativeScriptUIView Fabric +// component. Generalizes upstream react-native-screens' RNSScreenState: +// when the host view is adopted as a UIViewController's view and UIKit sizes +// it (a pushed/root screen under a UINavigationController), the resolved size +// is pushed into a custom shadow-node State; the custom ComponentDescriptor's +// adopt() reads that size and calls YogaLayoutableShadowNode::setSize(), so +// Yoga re-lays-out the hosted React subtree at the real UIKit size WITHOUT any +// manual per-view repair walk. The generic codegen'd component ships an empty +// StateData and cannot do this; this custom shadow node/state/descriptor +// replaces it (the ComponentView's +componentDescriptorProvider returns the +// descriptor below, which is authoritative for components that have a native +// ComponentView). + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace facebook { +namespace react { + +// State data carrying the UIKit-resolved container size. Mirrors the iOS +// (non-Android) shape of RNSScreenState — a plain value with a Shared alias +// and the two constructors Fabric needs. +class JSI_EXPORT NativeScriptUIViewSizeStateData final { + public: + using Shared = std::shared_ptr; + + NativeScriptUIViewSizeStateData() {} + explicit NativeScriptUIViewSizeStateData(Size frameSize_) + : frameSize(frameSize_) {} + + Size frameSize{}; +}; + +// Reuse the codegen'd component name so this shadow node's ComponentHandle +// matches the generated one; the ComponentView's provider then replaces the +// generated descriptor for the exact same component. +using NativeScriptUIViewSizedShadowNode = ConcreteViewShadowNode< + NativeScriptUIViewComponentName, + NativeScriptUIViewProps, + NativeScriptUIViewEventEmitter, + NativeScriptUIViewSizeStateData>; + +class NativeScriptUIViewSizedComponentDescriptor final + : public ConcreteComponentDescriptor { + public: + using ConcreteComponentDescriptor::ConcreteComponentDescriptor; + + void adopt(ShadowNode& shadowNode) const override { + auto& layoutableShadowNode = + static_cast(shadowNode); + + auto state = std::static_pointer_cast< + const NativeScriptUIViewSizedShadowNode::ConcreteState>( + shadowNode.getState()); + auto stateData = state->getData(); + + // A non-zero state size means UIKit has resolved the adopted container's + // size; force the Yoga node to that size so the hosted subtree lays out + // to the real dimensions. Zero size (default / non-adopted) leaves Yoga + // to compute the layout normally. + if (stateData.frameSize.width != 0 && stateData.frameSize.height != 0) { + layoutableShadowNode.setSize( + Size{stateData.frameSize.width, stateData.frameSize.height}); + } + + ConcreteComponentDescriptor::adopt(shadowNode); + } +}; + +} // namespace react +} // namespace facebook diff --git a/packages/react-native/ios/NativeScriptNativeApiModule.mm b/packages/react-native/ios/NativeScriptNativeApiModule.mm index 99722918c..516fc7002 100644 --- a/packages/react-native/ios/NativeScriptNativeApiModule.mm +++ b/packages/react-native/ios/NativeScriptNativeApiModule.mm @@ -2,17 +2,31 @@ #import #import +#import +#include #include #include #include #include +#import #include "NativeApiJsiReactNative.h" +#include "../native-api/ffi/objc/shared/bridge/InteropProfiler.h" #include "NativeScriptUIKitHost.h" +#import "Fabric/NativeScriptUIViewComponentView.h" #import +#import #import +#if __has_include() && \ + __has_include() +#import +#include +#define NATIVESCRIPT_RN_FABRIC_VIEW_TRAITS_AVAILABLE 1 +#else +#define NATIVESCRIPT_RN_FABRIC_VIEW_TRAITS_AVAILABLE 0 +#endif #import #import #import @@ -107,6 +121,453 @@ bool nativeApiInstalled(facebook::jsi::Runtime& runtime) { return [NSString stringWithFormat:@"%p", object]; } +id nativeScriptNSObjectFromHandle(NSString* handle) { + if (handle == nil || handle.length == 0) { + return nil; + } + + const char* text = handle.UTF8String; + if (text == nullptr || text[0] == '\0') { + return nil; + } + + char* end = nullptr; + unsigned long long address = strtoull(text, &end, 0); + if (address == 0 || end == text || (end != nullptr && *end != '\0')) { + return nil; + } + + return reinterpret_cast(static_cast(address)); +} + +NSString* nativeScriptStringFromJSIValue(facebook::jsi::Runtime& runtime, + const facebook::jsi::Value& value) { + if (!value.isString()) { + return nil; + } + + std::string text = value.asString(runtime).utf8(runtime); + return [NSString stringWithUTF8String:text.c_str()]; +} + +id nativeScriptObjCSelectorArgumentFromJSIValue(facebook::jsi::Runtime& runtime, + const facebook::jsi::Value& value) { + if (value.isNull() || value.isUndefined()) { + return [NSNull null]; + } + if (value.isBool()) { + return [NSNumber numberWithBool:value.getBool() ? YES : NO]; + } + if (value.isNumber()) { + return [NSNumber numberWithDouble:value.getNumber()]; + } + if (value.isString()) { + NSString* text = nativeScriptStringFromJSIValue(runtime, value); + id object = nativeScriptNSObjectFromHandle(text); + return object != nil ? object : text; + } + if (value.isObject()) { + facebook::jsi::Object object = value.asObject(runtime); + if (object.isArray(runtime)) { + facebook::jsi::Array array = object.asArray(runtime); + size_t length = array.length(runtime); + NSMutableArray* result = [NSMutableArray arrayWithCapacity:length]; + for (size_t index = 0; index < length; index += 1) { + id item = + nativeScriptObjCSelectorArgumentFromJSIValue(runtime, array.getValueAtIndex(runtime, index)); + [result addObject:item != nil ? item : [NSNull null]]; + } + return result; + } + } + + return [NSNull null]; +} + +NSArray* nativeScriptObjCSelectorArgumentsFromJSIValue( + facebook::jsi::Runtime& runtime, + const facebook::jsi::Value& value) { + if (!value.isObject()) { + return @[]; + } + + facebook::jsi::Object object = value.asObject(runtime); + if (!object.isArray(runtime)) { + return @[]; + } + + facebook::jsi::Array array = object.asArray(runtime); + size_t length = array.length(runtime); + NSMutableArray* result = [NSMutableArray arrayWithCapacity:length]; + for (size_t index = 0; index < length; index += 1) { + id argument = + nativeScriptObjCSelectorArgumentFromJSIValue(runtime, array.getValueAtIndex(runtime, index)); + [result addObject:argument != nil ? argument : [NSNull null]]; + } + return result; +} + +const char* nativeScriptSkipObjCTypeQualifiers(const char* type) { + if (type == nullptr) { + return ""; + } + + while (*type == 'r' || *type == 'n' || *type == 'N' || *type == 'o' || + *type == 'O' || *type == 'R' || *type == 'V') { + type += 1; + } + return type; +} + +BOOL nativeScriptSetInvocationArgument(NSInvocation* invocation, + const char* rawType, + id value, + NSUInteger index) { + const char* type = nativeScriptSkipObjCTypeQualifiers(rawType); + const char code = type[0]; + + if (code == '@' || code == '#') { + id object = value == [NSNull null] ? nil : value; + [invocation setArgument:&object atIndex:index]; + return YES; + } + + NSNumber* number = [value isKindOfClass:NSNumber.class] ? (NSNumber*)value : nil; + if (number == nil) { + return NO; + } + + switch (code) { + case 'B': { + bool boolValue = number.boolValue; + [invocation setArgument:&boolValue atIndex:index]; + return YES; + } + case 'c': { + BOOL boolValue = number.boolValue ? YES : NO; + [invocation setArgument:&boolValue atIndex:index]; + return YES; + } + case 'i': { + int intValue = number.intValue; + [invocation setArgument:&intValue atIndex:index]; + return YES; + } + case 's': { + short shortValue = number.shortValue; + [invocation setArgument:&shortValue atIndex:index]; + return YES; + } + case 'l': { + long longValue = number.longValue; + [invocation setArgument:&longValue atIndex:index]; + return YES; + } + case 'q': { + long long longLongValue = number.longLongValue; + [invocation setArgument:&longLongValue atIndex:index]; + return YES; + } + case 'C': { + unsigned char charValue = number.unsignedCharValue; + [invocation setArgument:&charValue atIndex:index]; + return YES; + } + case 'I': { + unsigned int intValue = number.unsignedIntValue; + [invocation setArgument:&intValue atIndex:index]; + return YES; + } + case 'S': { + unsigned short shortValue = number.unsignedShortValue; + [invocation setArgument:&shortValue atIndex:index]; + return YES; + } + case 'L': { + unsigned long longValue = number.unsignedLongValue; + [invocation setArgument:&longValue atIndex:index]; + return YES; + } + case 'Q': { + unsigned long long longLongValue = number.unsignedLongLongValue; + [invocation setArgument:&longLongValue atIndex:index]; + return YES; + } + case 'f': { + float floatValue = number.floatValue; + [invocation setArgument:&floatValue atIndex:index]; + return YES; + } + case 'd': { + double doubleValue = number.doubleValue; + [invocation setArgument:&doubleValue atIndex:index]; + return YES; + } + default: + return NO; + } +} + +facebook::jsi::Value nativeScriptJSIValueFromInvocationReturn( + facebook::jsi::Runtime& runtime, + NSInvocation* invocation, + const char* rawType) { + const char* type = nativeScriptSkipObjCTypeQualifiers(rawType); + const char code = type[0]; + + if (code == 'v') { + return facebook::jsi::Value(true); + } + if (code == '@' || code == '#') { + __unsafe_unretained id object = nil; + [invocation getReturnValue:&object]; + if (object == nil) { + return facebook::jsi::Value::null(); + } + NSString* handle = nativeScriptHandleFromNSObject(object); + return facebook::jsi::String::createFromUtf8(runtime, handle.UTF8String); + } + if (code == 'B') { + bool boolValue = false; + [invocation getReturnValue:&boolValue]; + return facebook::jsi::Value(boolValue); + } + if (code == 'c') { + BOOL boolValue = NO; + [invocation getReturnValue:&boolValue]; + return facebook::jsi::Value(boolValue == YES); + } + if (code == 'i') { + int intValue = 0; + [invocation getReturnValue:&intValue]; + return facebook::jsi::Value(static_cast(intValue)); + } + if (code == 's') { + short shortValue = 0; + [invocation getReturnValue:&shortValue]; + return facebook::jsi::Value(static_cast(shortValue)); + } + if (code == 'l') { + long longValue = 0; + [invocation getReturnValue:&longValue]; + return facebook::jsi::Value(static_cast(longValue)); + } + if (code == 'q') { + long long longLongValue = 0; + [invocation getReturnValue:&longLongValue]; + return facebook::jsi::Value(static_cast(longLongValue)); + } + if (code == 'C') { + unsigned char charValue = 0; + [invocation getReturnValue:&charValue]; + return facebook::jsi::Value(static_cast(charValue)); + } + if (code == 'I') { + unsigned int intValue = 0; + [invocation getReturnValue:&intValue]; + return facebook::jsi::Value(static_cast(intValue)); + } + if (code == 'S') { + unsigned short shortValue = 0; + [invocation getReturnValue:&shortValue]; + return facebook::jsi::Value(static_cast(shortValue)); + } + if (code == 'L') { + unsigned long longValue = 0; + [invocation getReturnValue:&longValue]; + return facebook::jsi::Value(static_cast(longValue)); + } + if (code == 'Q') { + unsigned long long longLongValue = 0; + [invocation getReturnValue:&longLongValue]; + return facebook::jsi::Value(static_cast(longLongValue)); + } + if (code == 'f') { + float floatValue = 0; + [invocation getReturnValue:&floatValue]; + return facebook::jsi::Value(static_cast(floatValue)); + } + if (code == 'd') { + double doubleValue = 0; + [invocation getReturnValue:&doubleValue]; + return facebook::jsi::Value(doubleValue); + } + + return facebook::jsi::Value(true); +} + +facebook::jsi::Value nativeScriptInvokeObjCSelectorFromHandles( + facebook::jsi::Runtime& runtime, + NSString* targetHandle, + NSString* selectorName, + NSArray* arguments) { + id target = nativeScriptNSObjectFromHandle(targetHandle); + if (target == nil || selectorName.length == 0) { + return facebook::jsi::Value(false); + } + + SEL selector = NSSelectorFromString(selectorName); + if (selector == nil || ![target respondsToSelector:selector]) { + return facebook::jsi::Value(false); + } + + NSMethodSignature* signature = [target methodSignatureForSelector:selector]; + if (signature == nil) { + return facebook::jsi::Value(false); + } + + NSUInteger expectedArguments = signature.numberOfArguments >= 2 + ? signature.numberOfArguments - 2 + : 0; + if (arguments.count != expectedArguments) { + return facebook::jsi::Value(false); + } + + NSInvocation* invocation = [NSInvocation invocationWithMethodSignature:signature]; + invocation.target = target; + invocation.selector = selector; + for (NSUInteger index = 0; index < expectedArguments; index += 1) { + if (!nativeScriptSetInvocationArgument( + invocation, + [signature getArgumentTypeAtIndex:index + 2], + arguments[index], + index + 2)) { + return facebook::jsi::Value(false); + } + } + + [invocation invoke]; + return nativeScriptJSIValueFromInvocationReturn( + runtime, invocation, signature.methodReturnType); +} + +#if NATIVESCRIPT_RN_FABRIC_VIEW_TRAITS_AVAILABLE +void setOptionalYogaFloat(facebook::jsi::Runtime& runtime, + facebook::jsi::Object& object, + const char* name, + facebook::yoga::FloatOptional value) { + if (value.isDefined()) { + object.setProperty(runtime, name, static_cast(value.unwrap())); + return; + } + + object.setProperty(runtime, name, facebook::jsi::Value::null()); +} + +void setRectProperties(facebook::jsi::Runtime& runtime, + facebook::jsi::Object& object, + const char* prefix, + const facebook::react::Rect& rect) { + std::string xName = std::string(prefix) + "X"; + std::string yName = std::string(prefix) + "Y"; + std::string widthName = std::string(prefix) + "Width"; + std::string heightName = std::string(prefix) + "Height"; + + object.setProperty(runtime, xName.c_str(), static_cast(rect.origin.x)); + object.setProperty(runtime, yName.c_str(), static_cast(rect.origin.y)); + object.setProperty(runtime, widthName.c_str(), static_cast(rect.size.width)); + object.setProperty(runtime, heightName.c_str(), static_cast(rect.size.height)); +} + +const facebook::react::LayoutMetrics* layoutMetricsForFabricComponentView(id object) { + Class currentClass = object_getClass(object); + + while (currentClass != Nil) { + Ivar layoutMetricsIvar = class_getInstanceVariable(currentClass, "_layoutMetrics"); + if (layoutMetricsIvar != nullptr) { + ptrdiff_t offset = ivar_getOffset(layoutMetricsIvar); + if (offset >= 0) { + auto* storage = reinterpret_cast(object) + offset; + return reinterpret_cast(storage); + } + return nullptr; + } + + currentClass = class_getSuperclass(currentClass); + } + + return nullptr; +} + +bool classHierarchyHasInstanceVariable(id object, const char* ivarName) { + if (object == nil || ivarName == nullptr) { + return false; + } + + Class currentClass = object_getClass(object); + while (currentClass != Nil) { + if (class_getInstanceVariable(currentClass, ivarName) != nullptr) { + return true; + } + currentClass = class_getSuperclass(currentClass); + } + + return false; +} +#endif + +facebook::jsi::Value reactFabricViewLayoutTraitsForHandle( + facebook::jsi::Runtime& runtime, + NSString* nativeHandle) { + facebook::jsi::Object traits(runtime); + traits.setProperty(runtime, "isFabricComponentView", false); + traits.setProperty(runtime, "hasYogaStyle", false); + traits.setProperty(runtime, "hasLayoutMetrics", false); + traits.setProperty(runtime, "flex", facebook::jsi::Value::null()); + traits.setProperty(runtime, "flexGrow", facebook::jsi::Value::null()); + traits.setProperty(runtime, "flexShrink", facebook::jsi::Value::null()); + + id object = nativeScriptNSObjectFromHandle(nativeHandle); + if (object == nil || ![object isKindOfClass:UIView.class]) { + return traits; + } + + UIView* view = (UIView*)object; + traits.setProperty(runtime, "frameX", static_cast(view.frame.origin.x)); + traits.setProperty(runtime, "frameY", static_cast(view.frame.origin.y)); + traits.setProperty(runtime, "frameWidth", static_cast(view.frame.size.width)); + traits.setProperty(runtime, "frameHeight", static_cast(view.frame.size.height)); + +#if NATIVESCRIPT_RN_FABRIC_VIEW_TRAITS_AVAILABLE + const facebook::react::LayoutMetrics* layoutMetrics = + layoutMetricsForFabricComponentView(object); + const bool hasPropsStorage = classHierarchyHasInstanceVariable(object, "_props"); + const bool hasConcreteFabricStorage = layoutMetrics != nullptr || hasPropsStorage; + if (!hasConcreteFabricStorage || + ![object conformsToProtocol:@protocol(RCTComponentViewProtocol)]) { + return traits; + } + + traits.setProperty(runtime, "isFabricComponentView", true); + + if (layoutMetrics != nullptr) { + traits.setProperty(runtime, "hasLayoutMetrics", true); + setRectProperties(runtime, traits, "layoutMetricsFrame", layoutMetrics->frame); + setRectProperties(runtime, traits, "layoutMetricsContentFrame", + layoutMetrics->getContentFrame()); + } + + if (!hasPropsStorage) { + return traits; + } + + id componentView = (id)object; + auto props = [componentView props]; + auto yogaProps = + std::dynamic_pointer_cast(props); + if (yogaProps == nullptr) { + return traits; + } + + traits.setProperty(runtime, "hasYogaStyle", true); + setOptionalYogaFloat(runtime, traits, "flex", yogaProps->yogaStyle.flex()); + setOptionalYogaFloat(runtime, traits, "flexGrow", yogaProps->yogaStyle.flexGrow()); + setOptionalYogaFloat(runtime, traits, "flexShrink", yogaProps->yogaStyle.flexShrink()); +#endif + + return traits; +} + RCTImageLoader* currentReactImageLoader() { RCTBridge* bridge = [RCTBridge currentBridge]; if (bridge == nil) { @@ -127,6 +588,155 @@ bool nativeApiInstalled(facebook::jsi::Runtime& runtime) { return imageLoader; } +id nativeScriptReactSurfacePresenter() { + RCTBridge* bridge = [RCTBridge currentBridge]; + if (bridge == nil) { + return nil; + } + + SEL selector = NSSelectorFromString(@"surfacePresenter"); + if ([bridge respondsToSelector:selector]) { + IMP implementation = [bridge methodForSelector:selector]; + if (implementation != nullptr) { + id (*surfacePresenter)(id, SEL) = + reinterpret_cast(implementation); + id presenter = surfacePresenter(bridge, selector); + if (presenter != nil) { + return presenter; + } + } + } + + @try { + return [bridge valueForKey:@"surfacePresenter"]; + } @catch (__unused NSException* exception) { + return nil; + } +} + +UIView* nativeScriptReactFabricComponentViewForTag(NSInteger tag) { + // Lock hierarchy (see runUIKitHostFunction / nativeScriptApplyUIKitHostPropsForFabricTag + // below): no code that may run with the worklet runtime's runtimeMutex_ held + // (i.e. anything reachable from worklet JS) may block waiting on the main + // queue. An off-main caller here is a programming error -- matches the + // no-op-off-main pattern used by every other entry point in + // NativeScriptUIView.mm. + if (![NSThread isMainThread]) { + return nil; + } + + NativeScriptUIViewComponentView* nativeScriptComponentView = + [NativeScriptUIViewComponentView nativeScriptComponentViewForReactTag:tag]; + if (nativeScriptComponentView != nil) { + return nativeScriptComponentView; + } + + id presenter = nativeScriptReactSurfacePresenter(); + SEL findSelector = NSSelectorFromString(@"findComponentViewWithTag_DO_NOT_USE_DEPRECATED:"); + if (presenter != nil && [presenter respondsToSelector:findSelector]) { + IMP implementation = [presenter methodForSelector:findSelector]; + if (implementation != nullptr) { + id (*findComponentView)(id, SEL, NSInteger) = + reinterpret_cast(implementation); + id componentView = findComponentView(presenter, findSelector, tag); + if ([componentView isKindOfClass:UIView.class]) { + return static_cast(componentView); + } + } + } + + nativeScriptComponentView = + [NativeScriptUIViewComponentView nativeScriptComponentViewForReactTag:tag]; + if (nativeScriptComponentView != nil) { + return nativeScriptComponentView; + } + + RCTBridge* bridge = [RCTBridge currentBridge]; + id uiManager = nil; + SEL uiManagerSelector = NSSelectorFromString(@"uiManager"); + if (bridge != nil && [bridge respondsToSelector:uiManagerSelector]) { + IMP implementation = [bridge methodForSelector:uiManagerSelector]; + if (implementation != nullptr) { + id (*getUIManager)(id, SEL) = reinterpret_cast(implementation); + uiManager = getUIManager(bridge, uiManagerSelector); + } + } + + SEL viewSelector = NSSelectorFromString(@"viewForReactTag:"); + if (uiManager != nil && [uiManager respondsToSelector:viewSelector]) { + IMP implementation = [uiManager methodForSelector:viewSelector]; + if (implementation != nullptr) { + id (*viewForReactTag)(id, SEL, NSNumber*) = + reinterpret_cast(implementation); + id view = viewForReactTag(uiManager, viewSelector, @(tag)); + if ([view isKindOfClass:UIView.class]) { + return static_cast(view); + } + } + } + + return nil; +} + +// Lock hierarchy (the AB-BA rule enforced across this file): MAIN may +// synchronously block waiting on the worklet runtime's runtimeMutex_ -- that +// is this codebase's synchronous host-lifecycle design (see +// runUIKitHostFunction below, which calls into WorkletRuntime::runSync and +// blocks main until the worklet body finishes). The converse must NEVER +// happen: no code that may run with runtimeMutex_ held -- i.e. anything +// reachable from worklet JS, including this function when it is invoked +// off-main from a worklet body -- may block waiting on the main queue. +// dispatch_sync(main) here used to do exactly that: worklet JS holds +// runtimeMutex_ while calling this off-main, dispatch_sync blocks that same +// thread on main, and main is concurrently blocked acquiring runtimeMutex_ +// inside runSync -- a classic AB-BA deadlock. So the off-main path below is +// fire-and-forget: dispatch_async to main and return nil immediately. Every +// call is preserved and applied in order (nothing is coalesced/dropped) -- +// an earlier per-tag-coalescing variant of this fix was tried and reverted +// because skipping intermediate applies could skip real, order-dependent +// side effects in applyNativeScriptUIKitHostProps: (transaction-commit +// scheduling, frame refresh), which surfaced as permanently blank screen +// content after sustained rapid navigation. Plain dispatch_async trades +// that correctness risk for a slower (but bounded, self-draining) main +// queue under heavy load. The JSI wrapper +// (__nativeScriptApplyUIKitHostPropsForFabricTag above) already maps +// nil/empty handles to a JS null, and JS callers fall back to +// previously-known handles (see index.ts's prepareUIKitHostOnUI / +// applyUIKitHostPropsForFabricTagOnUI bootstrap chain) until the async +// main-thread application completes and a subsequent update delivers the +// real handles. +// +// Corollary: any code that may run with runtimeMutex_ held on main must +// NEVER call snapshotViewAfterScreenUpdates:YES, +// drawViewHierarchyInRect:afterScreenUpdates:YES, +// resizableSnapshotViewFromRect:...afterScreenUpdates:YES, or any other +// CARenderServerCapture*-backed API. Those synchronously round-trip to the +// render server, and frame finalization there can transitively depend on +// the RN JS thread -- which may itself be parked on this same runtimeMutex_ +// inside runOnUISync -- producing the identical AB-BA deadlock described +// above (confirmed in the react-native-screens fork's +// RNSScreenNativeScriptController.setViewToSnapshot worklet). +NSDictionary* nativeScriptApplyUIKitHostPropsForFabricTag( + NSInteger tag, + NSDictionary* props) { + if (![NSThread isMainThread]) { + NSDictionary* retainedProps = [props retain]; + dispatch_async(dispatch_get_main_queue(), ^{ + nativeScriptApplyUIKitHostPropsForFabricTag(tag, retainedProps); + [retainedProps release]; + }); + return nil; + } + + UIView* componentView = nativeScriptReactFabricComponentViewForTag(tag); + if (![componentView isKindOfClass:NativeScriptUIViewComponentView.class]) { + return @{}; + } + + return [static_cast(componentView) + applyNativeScriptUIKitHostProps:props]; +} + UIImage* imageWithRenderingMode(UIImage* image, bool isTemplate) { if (image == nil) { return nil; @@ -146,9 +756,14 @@ bool nativeApiInstalled(facebook::jsi::Runtime& runtime) { return runtime; } -void setNativeScriptWorkletRuntime(std::shared_ptr runtime) { - std::lock_guard lock(nativeScriptWorkletRuntimeMutex()); - nativeScriptWorkletRuntime() = std::move(runtime); +std::atomic& nativeScriptWorkletRuntimeAcceptsCallbacks() { + static std::atomic accepts{false}; + return accepts; +} + +std::atomic& nativeScriptWorkletRuntimeGeneration() { + static std::atomic generation{1}; + return generation; } std::shared_ptr getNativeScriptWorkletRuntime() { @@ -156,6 +771,64 @@ void setNativeScriptWorkletRuntime(std::shared_ptr run return nativeScriptWorkletRuntime().lock(); } +void setNativeScriptWorkletRuntimeAcceptsCallbacks(bool accepts) { + nativeScriptWorkletRuntimeAcceptsCallbacks().store(accepts, std::memory_order_release); +} + +uint64_t prepareNativeScriptWorkletRuntime(std::shared_ptr runtime) { + std::lock_guard lock(nativeScriptWorkletRuntimeMutex()); + auto current = nativeScriptWorkletRuntime().lock(); + if (current == runtime) { + return nativeScriptWorkletRuntimeGeneration().load(std::memory_order_acquire); + } + + nativeScriptWorkletRuntimeAcceptsCallbacks().store(false, std::memory_order_release); + nativeScriptWorkletRuntime() = std::move(runtime); + return nativeScriptWorkletRuntimeGeneration().fetch_add(1, std::memory_order_acq_rel) + 1; +} + +bool nativeScriptWorkletRuntimeCallbacksAllowed() { + return nativeScriptWorkletRuntimeAcceptsCallbacks().load(std::memory_order_acquire) && + getNativeScriptWorkletRuntime() != nullptr; +} + +bool nativeScriptWorkletRuntimeCallbacksAllowed(uint64_t generation) { + return nativeScriptWorkletRuntimeAcceptsCallbacks().load(std::memory_order_acquire) && + nativeScriptWorkletRuntimeGeneration().load(std::memory_order_acquire) == generation && + getNativeScriptWorkletRuntime() != nullptr; +} + +void logNativeScriptWorkletRuntimeException(const char* context, const std::exception& error) { + NSLog(@"[NativeScriptNativeApi] %s threw: %s", context, error.what()); +} + +void logNativeScriptWorkletRuntimeUnknownException(const char* context) { + NSLog(@"[NativeScriptNativeApi] %s threw an unknown exception", context); +} + +void markNativeScriptWorkletRuntimeInvalidating() { + setNativeScriptWorkletRuntimeAcceptsCallbacks(false); +} + +void installNativeScriptBridgeInvalidationObserver() { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + NSNotificationCenter* center = [NSNotificationCenter defaultCenter]; + [center addObserverForName:RCTBridgeWillBeInvalidatedNotification + object:nil + queue:nil + usingBlock:^(__unused NSNotification* notification) { + markNativeScriptWorkletRuntimeInvalidating(); + }]; + [center addObserverForName:RCTBridgeWillInvalidateModulesNotification + object:nil + queue:nil + usingBlock:^(__unused NSNotification* notification) { + markNativeScriptWorkletRuntimeInvalidating(); + }]; + }); +} + NSString* stringProperty(facebook::jsi::Runtime& runtime, facebook::jsi::Object& object, const char* name) { auto value = object.getProperty(runtime, name); @@ -182,6 +855,7 @@ id imageSourceFromJSIValue(facebook::jsi::Runtime& runtime, void callImageLoadCallback( std::weak_ptr workletRuntimeWeak, + uint64_t workletRuntimeGeneration, std::shared_ptr callback, UIImage* image, NSString* errorMessage) { @@ -190,6 +864,11 @@ void callImageLoadCallback( retainedImage != nil ? nativeScriptHandleFromNSObject(retainedImage).UTF8String : ""; std::string errorText = errorMessage.UTF8String != nullptr ? errorMessage.UTF8String : ""; + if (!nativeScriptWorkletRuntimeCallbacksAllowed(workletRuntimeGeneration)) { + [retainedImage release]; + return; + } + auto runtimeStrong = workletRuntimeWeak.lock(); if (runtimeStrong == nullptr) { [retainedImage release]; @@ -198,7 +877,15 @@ void callImageLoadCallback( runtimeStrong->schedule( [callback = std::move(callback), imageHandle = std::move(imageHandle), - errorText = std::move(errorText), retainedImage](facebook::jsi::Runtime& runtime) mutable { + errorText = std::move(errorText), retainedImage, workletRuntimeGeneration]( + facebook::jsi::Runtime& runtime) mutable { + if (!nativeScriptWorkletRuntimeCallbacksAllowed(workletRuntimeGeneration)) { + dispatch_async(dispatch_get_main_queue(), ^{ + [retainedImage release]; + }); + return; + } + facebook::jsi::Value imageValue = imageHandle.empty() ? facebook::jsi::Value::null() @@ -243,12 +930,75 @@ void callImageLoadCallback( return handles; } +// NS_NS_HOST_PROFILE=1 logs every host lifecycle crossing over the threshold +// so transaction-time cost can be attributed per host/phase. +struct NativeScriptHostCallProfiler { + CFAbsoluteTime start; + NSString* hostId; + NSString* phase; + BOOL enabled; + NativeScriptHostCallProfiler(NSString* aHostId, NSString* aPhase) + : start(0), hostId(aHostId), phase(aPhase) { + static const BOOL profileEnabled = getenv("NS_NS_HOST_PROFILE") != nullptr; + enabled = profileEnabled; + if (enabled) { + start = CFAbsoluteTimeGetCurrent(); + } + } + ~NativeScriptHostCallProfiler() { + if (!enabled) { + return; + } + const double ms = (CFAbsoluteTimeGetCurrent() - start) * 1000.0; + if (ms >= 2.0) { + NSLog(@"NS_NS_HOST_PROFILE %@ phase=%@ ms=%.1f", hostId ?: @"?", + phase.length > 0 ? phase : @"create", ms); + } + } +}; + +// Lock hierarchy (enforced across this file, see +// nativeScriptApplyUIKitHostPropsForFabricTag above for the other +// direction): this function is main-thread-only (guarded below) and calls +// into WorkletRuntime::runSync, which blocks the calling (main) thread until +// it acquires the worklet runtime's runtimeMutex_ and the worklet body +// finishes running. That is fine -- MAIN is allowed to synchronously wait on +// the worklet mutex; this is our synchronous host-lifecycle design. What is +// NOT allowed is the converse: nothing that may run with runtimeMutex_ held +// (i.e. anything reachable from the worklet body invoked via runSync below, +// including any host function it calls back into) may wait on the main +// queue (no dispatch_sync/dispatch_get_main_queue with wait, no semaphores, +// no waitUntilDone:YES) -- doing so while main is here waiting on +// runtimeMutex_ is exactly the AB-BA deadlock this file's off-main paths are +// written to avoid. +// +// The same rule extends to any synchronous render-server capture API: +// snapshotViewAfterScreenUpdates:YES, +// drawViewHierarchyInRect:afterScreenUpdates:YES, +// resizableSnapshotViewFromRect:...afterScreenUpdates:YES, and any other +// CARenderServerCapture*-backed call are just as forbidden as +// dispatch_sync(main) for code reachable from a worklet body running with +// runtimeMutex_ held -- the render server's post-update frame can depend on +// the RN JS thread, which may be the very thread parked on runtimeMutex_ +// here in runSync. NSDictionary* runUIKitHostFunction(NSString* hostId, NSString* phase, + NSString* propsJson, + NSString* transactionJson, + NSString* nativeMountInfoJson, const char* globalName, const char* logAction) { if (hostId.length == 0 || ![NSThread isMainThread]) { return nil; } + NativeScriptHostCallProfiler profiler(hostId, phase); + const uint64_t profileInteropCallsStart = + ::nsInteropProfiler::gCalls.load(std::memory_order_relaxed); + const uint64_t profileInteropNsStart = + ::nsInteropProfiler::gNs.load(std::memory_order_relaxed); + + if (!nativeScriptWorkletRuntimeCallbacksAllowed()) { + return nil; + } auto workletRuntime = getNativeScriptWorkletRuntime(); if (workletRuntime == nullptr) { @@ -261,11 +1011,26 @@ void callImageLoadCallback( } std::string phaseString = phase.UTF8String != nullptr ? phase.UTF8String : ""; - + std::string propsJsonString = + propsJson.UTF8String != nullptr ? propsJson.UTF8String : ""; + std::string transactionJsonString = + transactionJson.UTF8String != nullptr ? transactionJson.UTF8String : ""; + std::string nativeMountInfoJsonString = + nativeMountInfoJson.UTF8String != nullptr ? nativeMountInfoJson.UTF8String : ""; try { - return workletRuntime->runSync( + static const bool profileHostCallsInnerFlag = getenv("NS_NS_HOST_PROFILE") != nullptr; + const bool profileHostCallsInner = profileHostCallsInnerFlag; + CFAbsoluteTime lambdaStart = 0; + NSDictionary* result = workletRuntime->runSync( [hostIdString = std::move(hostIdString), phaseString = std::move(phaseString), - globalName](facebook::jsi::Runtime& runtime) -> NSDictionary* { + propsJsonString = std::move(propsJsonString), + transactionJsonString = std::move(transactionJsonString), + nativeMountInfoJsonString = std::move(nativeMountInfoJsonString), + globalName, &lambdaStart, + profileHostCallsInner](facebook::jsi::Runtime& runtime) -> NSDictionary* { + if (profileHostCallsInner) { + lambdaStart = CFAbsoluteTimeGetCurrent(); + } auto global = runtime.global(); auto functionValue = global.getProperty(runtime, globalName); if (!functionValue.isObject()) { @@ -279,14 +1044,88 @@ void callImageLoadCallback( auto function = functionObject.asFunction(runtime); auto hostIdValue = facebook::jsi::String::createFromUtf8(runtime, hostIdString); + auto propsJsonValue = facebook::jsi::String::createFromUtf8(runtime, propsJsonString); if (phaseString.empty()) { + auto nativeMountInfoJsonValue = + facebook::jsi::String::createFromUtf8(runtime, nativeMountInfoJsonString); + if (!propsJsonString.empty() && !nativeMountInfoJsonString.empty()) { + return handlesFromJSIValue( + runtime, function.call(runtime, hostIdValue, propsJsonValue, + nativeMountInfoJsonValue)); + } + if (!nativeMountInfoJsonString.empty()) { + auto emptyPropsJsonValue = facebook::jsi::String::createFromUtf8(runtime, ""); + return handlesFromJSIValue( + runtime, function.call(runtime, hostIdValue, emptyPropsJsonValue, + nativeMountInfoJsonValue)); + } + if (!propsJsonString.empty()) { + return handlesFromJSIValue( + runtime, function.call(runtime, hostIdValue, propsJsonValue)); + } return handlesFromJSIValue(runtime, function.call(runtime, hostIdValue)); } - return handlesFromJSIValue( - runtime, function.call(runtime, hostIdValue, - facebook::jsi::String::createFromUtf8(runtime, phaseString))); + auto phaseValue = facebook::jsi::String::createFromUtf8(runtime, phaseString); + auto transactionJsonValue = + facebook::jsi::String::createFromUtf8(runtime, transactionJsonString); + auto nativeMountInfoJsonValue = + facebook::jsi::String::createFromUtf8(runtime, nativeMountInfoJsonString); + if (!propsJsonString.empty() && !transactionJsonString.empty() && + !nativeMountInfoJsonString.empty()) { + return handlesFromJSIValue( + runtime, function.call(runtime, hostIdValue, phaseValue, propsJsonValue, + transactionJsonValue, nativeMountInfoJsonValue)); + } + if (!transactionJsonString.empty() && !nativeMountInfoJsonString.empty()) { + auto emptyPropsJsonValue = facebook::jsi::String::createFromUtf8(runtime, ""); + return handlesFromJSIValue( + runtime, function.call(runtime, hostIdValue, phaseValue, emptyPropsJsonValue, + transactionJsonValue, nativeMountInfoJsonValue)); + } + if (!propsJsonString.empty() && !nativeMountInfoJsonString.empty()) { + auto emptyTransactionJsonValue = facebook::jsi::String::createFromUtf8(runtime, ""); + return handlesFromJSIValue( + runtime, function.call(runtime, hostIdValue, phaseValue, propsJsonValue, + emptyTransactionJsonValue, nativeMountInfoJsonValue)); + } + if (!nativeMountInfoJsonString.empty()) { + auto emptyPropsJsonValue = facebook::jsi::String::createFromUtf8(runtime, ""); + auto emptyTransactionJsonValue = facebook::jsi::String::createFromUtf8(runtime, ""); + return handlesFromJSIValue( + runtime, function.call(runtime, hostIdValue, phaseValue, emptyPropsJsonValue, + emptyTransactionJsonValue, nativeMountInfoJsonValue)); + } + if (!propsJsonString.empty() && !transactionJsonString.empty()) { + return handlesFromJSIValue(runtime, + function.call(runtime, hostIdValue, phaseValue, + propsJsonValue, transactionJsonValue)); + } + if (!propsJsonString.empty()) { + return handlesFromJSIValue( + runtime, function.call(runtime, hostIdValue, phaseValue, propsJsonValue)); + } + if (!transactionJsonString.empty()) { + auto emptyPropsJsonValue = facebook::jsi::String::createFromUtf8(runtime, ""); + return handlesFromJSIValue(runtime, + function.call(runtime, hostIdValue, phaseValue, + emptyPropsJsonValue, transactionJsonValue)); + } + + return handlesFromJSIValue(runtime, function.call(runtime, hostIdValue, phaseValue)); }); + if (profileHostCallsInner && lambdaStart > 0) { + const double innerMs = (CFAbsoluteTimeGetCurrent() - lambdaStart) * 1000.0; + if (innerMs >= 2.0) { + const uint64_t callsNow = ::nsInteropProfiler::gCalls.load(std::memory_order_relaxed); + const uint64_t nsNow = ::nsInteropProfiler::gNs.load(std::memory_order_relaxed); + NSLog(@"NS_NS_HOST_PROFILE_INNER %@ phase=%@ jsMs=%.1f interopCalls=%llu interopMs=%.1f", + hostId, phase.length > 0 ? phase : @"create", innerMs, + (unsigned long long)(callsNow - profileInteropCallsStart), + (double)(nsNow - profileInteropNsStart) / 1e6); + } + } + return result; } catch (const std::exception& error) { NSLog(@"NativeScript failed to %s UIKit host %@: %s", logAction, hostId, error.what()); } catch (...) { @@ -297,14 +1136,33 @@ void callImageLoadCallback( } // namespace -NSDictionary* NativeScriptCreateUIKitHost(NSString* hostId) { - return runUIKitHostFunction(hostId, nil, "__nativeScriptCreateUIKitHostFromNative", "create"); +NSDictionary* NativeScriptCreateUIKitHost(NSString* hostId, + NSString* propsJson) { + return runUIKitHostFunction(hostId, nil, propsJson, nil, nil, + "__nativeScriptCreateUIKitHostFromNative", "create"); +} + +NSDictionary* NativeScriptCreateUIKitHostWithInfo( + NSString* hostId, NSString* propsJson, NSString* nativeMountInfoJson) { + return runUIKitHostFunction(hostId, nil, propsJson, nil, nativeMountInfoJson, + "__nativeScriptCreateUIKitHostFromNative", "create"); } NSDictionary* NativeScriptRunUIKitHostLifecycle(NSString* hostId, - NSString* phase) { - return runUIKitHostFunction(hostId, phase, "__nativeScriptRunUIKitHostLifecycleFromNative", - "run"); + NSString* phase, + NSString* propsJson) { + return runUIKitHostFunction(hostId, phase, propsJson, nil, nil, + "__nativeScriptRunUIKitHostLifecycleFromNative", "run"); +} + +NSDictionary* NativeScriptRunUIKitHostLifecycleWithInfo( + NSString* hostId, + NSString* phase, + NSString* propsJson, + NSString* transactionJson, + NSString* nativeMountInfoJson) { + return runUIKitHostFunction(hostId, phase, propsJson, transactionJson, nativeMountInfoJson, + "__nativeScriptRunUIKitHostLifecycleFromNative", "run"); } namespace facebook::react { @@ -344,14 +1202,15 @@ void callImageLoadCallback( return false; } - setNativeScriptWorkletRuntime(holder->runtime_); + installNativeScriptBridgeInvalidationObserver(); + uint64_t workletRuntimeGeneration = prepareNativeScriptWorkletRuntime(holder->runtime_); std::string resolvedMetadataPath = metadataPath.empty() ? bundledMetadataPath() : metadataPath; auto jsInvoker = jsInvoker_; auto workletRuntimeRef = holder->runtime_; return holder->runtime_->runSync( [jsInvoker = std::move(jsInvoker), resolvedMetadataPath = std::move(resolvedMetadataPath), - workletRuntimeRef = std::move(workletRuntimeRef)]( + workletRuntimeRef = std::move(workletRuntimeRef), workletRuntimeGeneration]( jsi::Runtime& workletRuntime) -> bool { if (!nativeApiInstalled(workletRuntime)) { std::weak_ptr workletRuntimeWeak(workletRuntimeRef); @@ -360,31 +1219,173 @@ void callImageLoadCallback( auto config = nativescript::MakeReactNativeNativeApiJsiConfig( jsInvoker, nullptr, metadataPathArg, nullptr, "__nativeScriptNativeApi"); - config.installGlobalSymbols = true; + config.installGlobalSymbols = false; config.invokeCallbacksOnNativeCallerThread = true; + config.callbackInvocationAllowed = [workletRuntimeGeneration]() { + return nativeScriptWorkletRuntimeCallbacksAllowed(workletRuntimeGeneration); + }; config.runtimeCallbackInvoker = - [workletRuntimeWeak](std::function task) mutable { + [workletRuntimeWeak, workletRuntimeGeneration]( + std::function task) mutable { + if (!nativeScriptWorkletRuntimeCallbacksAllowed(workletRuntimeGeneration)) { + return; + } auto runtimeStrong = workletRuntimeWeak.lock(); if (runtimeStrong == nullptr) { return; } - auto taskBox = - std::make_shared>(std::move(task)); - dispatch_semaphore_t done = dispatch_semaphore_create(0); - runtimeStrong->schedule( - [taskBox = std::move(taskBox), done](jsi::Runtime&) mutable { - (*taskBox)(); - dispatch_semaphore_signal(done); - }); - dispatch_semaphore_wait(done, DISPATCH_TIME_FOREVER); + // Execute the callback inline on the calling thread under the + // worklet runtime's recursive mutex, exactly like the host + // lifecycle runSync paths. The previous schedule-and-wait + // design blocked the caller (usually the main thread) on a + // 2-second semaphore and PROCEEDED WITHOUT the callback when + // it timed out: bursts of UIKit delegate callbacks during + // interactive dismissal serialized into multi-second freezes + // and silently dropped dismissal bookkeeping, wedging + // navigation state. runSync is reentrant for nested + // callbacks on a thread that already holds the runtime and + // never drops the invocation. + try { + runtimeStrong->runSync( + [&task, workletRuntimeGeneration](jsi::Runtime&) { + if (nativeScriptWorkletRuntimeCallbacksAllowed( + workletRuntimeGeneration)) { + task(); + } + }); + } catch (const std::exception& error) { + logNativeScriptWorkletRuntimeException( + "runtimeCallbackInvoker", error); + } catch (...) { + logNativeScriptWorkletRuntimeUnknownException( + "runtimeCallbackInvoker"); + } }; nativescript::InstallNativeApiJSI(workletRuntime, config); + // Worklet-runtime logging bridge: os_log is otherwise unreachable + // from the UI/worklet runtime (neither NSLog nor console.warn there + // surface), so install a host function that forwards to NSLog. Lets + // fork worklet code emit debuggable output via globalThis.__nsLog(...). + workletRuntimeRef->runSync([](facebook::jsi::Runtime& logRuntime) { + logRuntime.global().setProperty( + logRuntime, "__nsLog", + facebook::jsi::Function::createFromHostFunction( + logRuntime, + facebook::jsi::PropNameID::forAscii(logRuntime, "__nsLog"), + 1, + [](facebook::jsi::Runtime& rt, const facebook::jsi::Value&, + const facebook::jsi::Value* args, + size_t count) -> facebook::jsi::Value { + if (count > 0 && args[0].isString()) { + std::string msg = args[0].getString(rt).utf8(rt); + NSLog(@"%s", msg.c_str()); + } + return facebook::jsi::Value::undefined(); + })); + }); + // Always-on interop-call counter: unlike __nsInteropCalls below, + // this does NOT require NS_NS_HOST_PROFILE and does not read + // nsInteropProfiler::gCalls (which only increments under that + // flag). It reads gCallsAlways, a plain unconditional atomic + // incremented on every interop dispatch with no timing and no + // profiling side effects -- a trustworthy signal for pop-perf + // gating without perturbing the call volume it measures. + workletRuntimeRef->runSync([](facebook::jsi::Runtime& counterRuntime) { + counterRuntime.global().setProperty( + counterRuntime, "__nsInteropCallCount", + facebook::jsi::Function::createFromHostFunction( + counterRuntime, + facebook::jsi::PropNameID::forAscii(counterRuntime, + "__nsInteropCallCount"), + 0, + [](facebook::jsi::Runtime&, const facebook::jsi::Value&, + const facebook::jsi::Value*, size_t) { + return facebook::jsi::Value( + (double)::nsInteropProfiler::gCallsAlways.load( + std::memory_order_relaxed)); + })); + }); + if (getenv("NS_NS_HOST_PROFILE") != nullptr) { + workletRuntimeRef->runSync([](facebook::jsi::Runtime& profileRuntime) { + profileRuntime.global().setProperty(profileRuntime, + "__NS_NS_HOST_PROFILE", true); + // Absolute bridged-call counter so worklet-side section timers + // can attribute interop call counts to JS sections. + profileRuntime.global().setProperty( + profileRuntime, "__nsInteropCalls", + facebook::jsi::Function::createFromHostFunction( + profileRuntime, + facebook::jsi::PropNameID::forAscii(profileRuntime, + "__nsInteropCalls"), + 0, + [](facebook::jsi::Runtime&, const facebook::jsi::Value&, + const facebook::jsi::Value*, size_t) { + return facebook::jsi::Value( + (double)::nsInteropProfiler::gCalls.load( + std::memory_order_relaxed)); + })); + }); + } } - auto refreshUIKitHostView = jsi::Function::createFromHostFunction( - workletRuntime, - jsi::PropNameID::forAscii(workletRuntime, "__nativeScriptRefreshUIKitHostView"), + setNativeScriptWorkletRuntimeAcceptsCallbacks(true); + + std::weak_ptr mainQueueWorkletRuntimeWeak(workletRuntimeRef); + auto dispatchAsyncOnMainQueue = jsi::Function::createFromHostFunction( + workletRuntime, + jsi::PropNameID::forAscii( + workletRuntime, + "__nativeScriptDispatchAsyncOnMainQueue"), + 1, + [mainQueueWorkletRuntimeWeak, workletRuntimeGeneration]( + jsi::Runtime& runtime, const jsi::Value&, const jsi::Value* args, + size_t count) -> jsi::Value { + if (count < 1 || !args[0].isObject() || + !args[0].asObject(runtime).isFunction(runtime)) { + return false; + } + if (!nativeScriptWorkletRuntimeCallbacksAllowed(workletRuntimeGeneration)) { + return false; + } + + auto callback = std::make_shared( + args[0].asObject(runtime).asFunction(runtime)); + dispatch_async(dispatch_get_main_queue(), ^{ + if (!nativeScriptWorkletRuntimeCallbacksAllowed(workletRuntimeGeneration)) { + return; + } + auto runtimeStrong = mainQueueWorkletRuntimeWeak.lock(); + if (runtimeStrong == nullptr) { + return; + } + runtimeStrong->schedule( + [callback, workletRuntimeGeneration](jsi::Runtime& runtime) { + if (!nativeScriptWorkletRuntimeCallbacksAllowed( + workletRuntimeGeneration)) { + return; + } + try { + callback->call(runtime); + } catch (const std::exception& error) { + logNativeScriptWorkletRuntimeException( + "dispatchAsyncOnMainQueue", error); + } catch (...) { + logNativeScriptWorkletRuntimeUnknownException( + "dispatchAsyncOnMainQueue"); + } + }); + }); + return true; + }); + workletRuntime.global().setProperty( + workletRuntime, + "__nativeScriptDispatchAsyncOnMainQueue", + std::move(dispatchAsyncOnMainQueue)); + + auto refreshUIKitHostView = jsi::Function::createFromHostFunction( + workletRuntime, + jsi::PropNameID::forAscii(workletRuntime, "__nativeScriptRefreshUIKitHostView"), 1, [](jsi::Runtime& runtime, const jsi::Value&, const jsi::Value* args, size_t count) -> jsi::Value { @@ -396,17 +1397,429 @@ void callImageLoadCallback( NSString* nativeHandle = [NSString stringWithUTF8String:handle.c_str()]; return NativeScriptRefreshUIKitHostView(nativeHandle) == YES; }); + workletRuntime.global().setProperty( + workletRuntime, "__nativeScriptRefreshUIKitHostView", std::move(refreshUIKitHostView)); + + auto refreshUIKitHostViewOwner = jsi::Function::createFromHostFunction( + workletRuntime, + jsi::PropNameID::forAscii( + workletRuntime, + "__nativeScriptRefreshUIKitHostViewOwner"), + 1, + [](jsi::Runtime& runtime, const jsi::Value&, const jsi::Value* args, + size_t count) -> jsi::Value { + if (count < 1 || !args[0].isString()) { + return false; + } + + std::string handle = args[0].asString(runtime).utf8(runtime); + NSString* nativeHandle = [NSString stringWithUTF8String:handle.c_str()]; + return NativeScriptRefreshUIKitHostViewOwner(nativeHandle) == YES; + }); + workletRuntime.global().setProperty( + workletRuntime, + "__nativeScriptRefreshUIKitHostViewOwner", + std::move(refreshUIKitHostViewOwner)); + + auto refreshUIKitHostViewDirectOwner = jsi::Function::createFromHostFunction( + workletRuntime, + jsi::PropNameID::forAscii( + workletRuntime, + "__nativeScriptRefreshUIKitHostViewDirectOwner"), + 1, + [](jsi::Runtime& runtime, const jsi::Value&, const jsi::Value* args, + size_t count) -> jsi::Value { + if (count < 1 || !args[0].isString()) { + return false; + } + + std::string handle = args[0].asString(runtime).utf8(runtime); + NSString* nativeHandle = [NSString stringWithUTF8String:handle.c_str()]; + return NativeScriptRefreshUIKitHostViewDirectOwner(nativeHandle) == YES; + }); + workletRuntime.global().setProperty( + workletRuntime, + "__nativeScriptRefreshUIKitHostViewDirectOwner", + std::move(refreshUIKitHostViewDirectOwner)); + + auto invalidateUIKitHostReadyOwner = jsi::Function::createFromHostFunction( + workletRuntime, + jsi::PropNameID::forAscii( + workletRuntime, + "__nativeScriptInvalidateUIKitHostReadyOwner"), + 1, + [](jsi::Runtime& runtime, const jsi::Value&, const jsi::Value* args, + size_t count) -> jsi::Value { + if (count < 1 || !args[0].isString()) { + return false; + } + + std::string handle = args[0].asString(runtime).utf8(runtime); + NSString* nativeHandle = [NSString stringWithUTF8String:handle.c_str()]; + return NativeScriptInvalidateUIKitHostReadyOwner(nativeHandle) == YES; + }); + workletRuntime.global().setProperty( + workletRuntime, + "__nativeScriptInvalidateUIKitHostReadyOwner", + std::move(invalidateUIKitHostReadyOwner)); + + auto notifyUIKitAccessibilityLayoutChanged = jsi::Function::createFromHostFunction( + workletRuntime, + jsi::PropNameID::forAscii( + workletRuntime, + "__nativeScriptNotifyUIKitAccessibilityLayoutChanged"), + 1, + [](jsi::Runtime& runtime, const jsi::Value&, const jsi::Value* args, + size_t count) -> jsi::Value { + if (count < 1 || !args[0].isString()) { + return false; + } + + std::string handle = args[0].asString(runtime).utf8(runtime); + NSString* nativeHandle = [NSString stringWithUTF8String:handle.c_str()]; + return NativeScriptNotifyUIKitAccessibilityLayoutChanged(nativeHandle) == YES; + }); + workletRuntime.global().setProperty( + workletRuntime, + "__nativeScriptNotifyUIKitAccessibilityLayoutChanged", + std::move(notifyUIKitAccessibilityLayoutChanged)); + + auto flushUIKitHostView = jsi::Function::createFromHostFunction( + workletRuntime, + jsi::PropNameID::forAscii(workletRuntime, "__nativeScriptFlushUIKitHostView"), + 1, + [](jsi::Runtime& runtime, const jsi::Value&, const jsi::Value* args, + size_t count) -> jsi::Value { + if (count < 1 || !args[0].isString()) { + return false; + } + + std::string handle = args[0].asString(runtime).utf8(runtime); + NSString* nativeHandle = [NSString stringWithUTF8String:handle.c_str()]; + return NativeScriptFlushUIKitHostView(nativeHandle) == YES; + }); + workletRuntime.global().setProperty( + workletRuntime, + "__nativeScriptFlushUIKitHostView", + std::move(flushUIKitHostView)); + + auto flushUIKitHostViewOwner = jsi::Function::createFromHostFunction( + workletRuntime, + jsi::PropNameID::forAscii( + workletRuntime, + "__nativeScriptFlushUIKitHostViewOwner"), + 1, + [](jsi::Runtime& runtime, const jsi::Value&, const jsi::Value* args, + size_t count) -> jsi::Value { + if (count < 1 || !args[0].isString()) { + return false; + } + + std::string handle = args[0].asString(runtime).utf8(runtime); + NSString* nativeHandle = [NSString stringWithUTF8String:handle.c_str()]; + return NativeScriptFlushUIKitHostViewOwner(nativeHandle) == YES; + }); + workletRuntime.global().setProperty( + workletRuntime, + "__nativeScriptFlushUIKitHostViewOwner", + std::move(flushUIKitHostViewOwner)); + + auto uikitHostHandlesForView = jsi::Function::createFromHostFunction( + workletRuntime, + jsi::PropNameID::forAscii( + workletRuntime, + "__nativeScriptUIKitHostHandlesForView"), + 1, + [](jsi::Runtime& runtime, const jsi::Value&, const jsi::Value* args, + size_t count) -> jsi::Value { + if (count < 1 || !args[0].isString()) { + return jsi::Value::null(); + } + + std::string handle = args[0].asString(runtime).utf8(runtime); + NSString* nativeHandle = [NSString stringWithUTF8String:handle.c_str()]; + NSDictionary* handles = + NativeScriptUIKitHostHandlesForView(nativeHandle); + jsi::Object result(runtime); + result.setProperty( + runtime, + "componentViewHandle", + jsi::String::createFromUtf8( + runtime, (handles[@"componentViewHandle"] ?: @"").UTF8String)); + result.setProperty( + runtime, + "containerViewHandle", + jsi::String::createFromUtf8( + runtime, (handles[@"containerViewHandle"] ?: @"").UTF8String)); + result.setProperty( + runtime, + "nativeViewHandle", + jsi::String::createFromUtf8( + runtime, (handles[@"nativeViewHandle"] ?: @"").UTF8String)); + result.setProperty( + runtime, + "childrenViewHandle", + jsi::String::createFromUtf8( + runtime, (handles[@"childrenViewHandle"] ?: @"").UTF8String)); + result.setProperty( + runtime, + "controllerHandle", + jsi::String::createFromUtf8( + runtime, (handles[@"controllerHandle"] ?: @"").UTF8String)); + return result; + }); + workletRuntime.global().setProperty( + workletRuntime, + "__nativeScriptUIKitHostHandlesForView", + std::move(uikitHostHandlesForView)); + + auto uikitHostOwnerHandlesForView = jsi::Function::createFromHostFunction( + workletRuntime, + jsi::PropNameID::forAscii( + workletRuntime, + "__nativeScriptUIKitHostOwnerHandlesForView"), + 1, + [](jsi::Runtime& runtime, const jsi::Value&, const jsi::Value* args, + size_t count) -> jsi::Value { + if (count < 1 || !args[0].isString()) { + return jsi::Value::null(); + } + + std::string handle = args[0].asString(runtime).utf8(runtime); + NSString* nativeHandle = [NSString stringWithUTF8String:handle.c_str()]; + NSDictionary* handles = + NativeScriptUIKitHostOwnerHandlesForView(nativeHandle); + jsi::Object result(runtime); + result.setProperty( + runtime, + "componentViewHandle", + jsi::String::createFromUtf8( + runtime, (handles[@"componentViewHandle"] ?: @"").UTF8String)); + result.setProperty( + runtime, + "containerViewHandle", + jsi::String::createFromUtf8( + runtime, (handles[@"containerViewHandle"] ?: @"").UTF8String)); + result.setProperty( + runtime, + "nativeViewHandle", + jsi::String::createFromUtf8( + runtime, (handles[@"nativeViewHandle"] ?: @"").UTF8String)); + result.setProperty( + runtime, + "childrenViewHandle", + jsi::String::createFromUtf8( + runtime, (handles[@"childrenViewHandle"] ?: @"").UTF8String)); + result.setProperty( + runtime, + "controllerHandle", + jsi::String::createFromUtf8( + runtime, (handles[@"controllerHandle"] ?: @"").UTF8String)); + return result; + }); + workletRuntime.global().setProperty( + workletRuntime, + "__nativeScriptUIKitHostOwnerHandlesForView", + std::move(uikitHostOwnerHandlesForView)); + + auto applyUIKitHostPropsForFabricTag = jsi::Function::createFromHostFunction( + workletRuntime, + jsi::PropNameID::forAscii( + workletRuntime, + "__nativeScriptApplyUIKitHostPropsForFabricTag"), + 2, + [jsInvoker](jsi::Runtime& runtime, const jsi::Value&, const jsi::Value* args, + size_t count) -> jsi::Value { + if (count < 2 || !args[0].isNumber() || !args[1].isObject()) { + return jsi::Value::null(); + } + + NSInteger tag = static_cast(args[0].getNumber()); + id converted = + facebook::react::TurboModuleConvertUtils::convertJSIValueToObjCObject( + runtime, args[1], jsInvoker, YES); + NSDictionary* props = + [converted isKindOfClass:NSDictionary.class] + ? static_cast*>(converted) + : nil; + NSDictionary* handles = + nativeScriptApplyUIKitHostPropsForFabricTag(tag, props); + if (handles == nil || handles.count == 0) { + return jsi::Value::null(); + } + + jsi::Object result(runtime); + result.setProperty( + runtime, + "componentViewHandle", + jsi::String::createFromUtf8( + runtime, (handles[@"componentViewHandle"] ?: @"").UTF8String)); + result.setProperty( + runtime, + "containerViewHandle", + jsi::String::createFromUtf8( + runtime, (handles[@"containerViewHandle"] ?: @"").UTF8String)); + result.setProperty( + runtime, + "nativeViewHandle", + jsi::String::createFromUtf8( + runtime, (handles[@"nativeViewHandle"] ?: @"").UTF8String)); + result.setProperty( + runtime, + "childrenViewHandle", + jsi::String::createFromUtf8( + runtime, (handles[@"childrenViewHandle"] ?: @"").UTF8String)); + result.setProperty( + runtime, + "controllerHandle", + jsi::String::createFromUtf8( + runtime, (handles[@"controllerHandle"] ?: @"").UTF8String)); + return result; + }); + workletRuntime.global().setProperty( + workletRuntime, + "__nativeScriptApplyUIKitHostPropsForFabricTag", + std::move(applyUIKitHostPropsForFabricTag)); + + auto collectedUIKitHostChildren = jsi::Function::createFromHostFunction( + workletRuntime, + jsi::PropNameID::forAscii( + workletRuntime, + "__nativeScriptCollectedUIKitHostChildren"), + 1, + [](jsi::Runtime& runtime, const jsi::Value&, const jsi::Value* args, + size_t count) -> jsi::Value { + if (count < 1 || !args[0].isString()) { + return jsi::Value::null(); + } + + std::string handle = args[0].asString(runtime).utf8(runtime); + NSString* nativeHandle = [NSString stringWithUTF8String:handle.c_str()]; + NSArray* children = NativeScriptCollectedUIKitHostChildren(nativeHandle); + NSString* childrenHandle = nativeScriptHandleFromNSObject(children); + return facebook::jsi::String::createFromUtf8( + runtime, childrenHandle.UTF8String); + }); + workletRuntime.global().setProperty( + workletRuntime, + "__nativeScriptCollectedUIKitHostChildren", + std::move(collectedUIKitHostChildren)); + + auto reactFabricViewLayoutTraits = jsi::Function::createFromHostFunction( + workletRuntime, + jsi::PropNameID::forAscii( + workletRuntime, + "__nativeScriptReactFabricViewLayoutTraits"), + 1, + [](jsi::Runtime& runtime, const jsi::Value&, const jsi::Value* args, + size_t count) -> jsi::Value { + if (count < 1 || !args[0].isString()) { + return jsi::Value::null(); + } + + std::string handle = args[0].asString(runtime).utf8(runtime); + NSString* nativeHandle = [NSString stringWithUTF8String:handle.c_str()]; + return reactFabricViewLayoutTraitsForHandle(runtime, nativeHandle); + }); + workletRuntime.global().setProperty( + workletRuntime, + "__nativeScriptReactFabricViewLayoutTraits", + std::move(reactFabricViewLayoutTraits)); + + auto nearestViewControllerForView = + jsi::Function::createFromHostFunction( + workletRuntime, + jsi::PropNameID::forAscii( + workletRuntime, + "__nativeScriptNearestViewControllerForView"), + 1, + [](jsi::Runtime& runtime, const jsi::Value&, const jsi::Value* args, + size_t count) -> jsi::Value { + if (count < 1 || !args[0].isString()) { + return jsi::Value::null(); + } + + std::string view = args[0].asString(runtime).utf8(runtime); + NSString* viewHandle = [NSString stringWithUTF8String:view.c_str()]; + NSString* controllerHandle = + NativeScriptNearestViewControllerForView(viewHandle); + if (controllerHandle.length == 0) { + return jsi::Value::null(); + } + return jsi::String::createFromUtf8( + runtime, controllerHandle.UTF8String); + }); + workletRuntime.global().setProperty( + workletRuntime, + "__nativeScriptNearestViewControllerForView", + std::move(nearestViewControllerForView)); + + auto attachViewControllerToNearestParent = + jsi::Function::createFromHostFunction( + workletRuntime, + jsi::PropNameID::forAscii( + workletRuntime, + "__nativeScriptAttachViewControllerToNearestParent"), + 2, + [](jsi::Runtime& runtime, const jsi::Value&, const jsi::Value* args, + size_t count) -> jsi::Value { + if (count < 2 || !args[0].isString() || !args[1].isString()) { + return false; + } + + std::string controller = args[0].asString(runtime).utf8(runtime); + std::string view = args[1].asString(runtime).utf8(runtime); + BOOL allowRootParent = + count > 2 && args[2].isBool() && args[2].getBool() ? YES : NO; + NSString* controllerHandle = + [NSString stringWithUTF8String:controller.c_str()]; + NSString* viewHandle = [NSString stringWithUTF8String:view.c_str()]; + return NativeScriptAttachViewControllerToNearestParent( + controllerHandle, viewHandle, allowRootParent) == YES; + }); + workletRuntime.global().setProperty( + workletRuntime, + "__nativeScriptAttachViewControllerToNearestParent", + std::move(attachViewControllerToNearestParent)); + + auto invokeObjCSelector = + jsi::Function::createFromHostFunction( + workletRuntime, + jsi::PropNameID::forAscii( + workletRuntime, + "__nativeScriptInvokeObjCSelector"), + 3, + [](jsi::Runtime& runtime, const jsi::Value&, const jsi::Value* args, + size_t count) -> jsi::Value { + if (count < 2 || !args[0].isString() || !args[1].isString()) { + return false; + } + + NSString* targetHandle = + nativeScriptStringFromJSIValue(runtime, args[0]); + NSString* selectorName = + nativeScriptStringFromJSIValue(runtime, args[1]); + NSArray* selectorArguments = + count > 2 + ? nativeScriptObjCSelectorArgumentsFromJSIValue( + runtime, args[2]) + : @[]; + return nativeScriptInvokeObjCSelectorFromHandles( + runtime, targetHandle, selectorName, selectorArguments); + }); workletRuntime.global().setProperty( - workletRuntime, "__nativeScriptRefreshUIKitHostView", std::move(refreshUIKitHostView)); + workletRuntime, + "__nativeScriptInvokeObjCSelector", + std::move(invokeObjCSelector)); std::weak_ptr imageWorkletRuntimeWeak(workletRuntimeRef); auto loadImage = jsi::Function::createFromHostFunction( workletRuntime, jsi::PropNameID::forAscii(workletRuntime, "__nativeScriptLoadReactImage"), 3, - [jsInvoker, imageWorkletRuntimeWeak](jsi::Runtime& runtime, const jsi::Value&, - const jsi::Value* args, - size_t count) -> jsi::Value { + [jsInvoker, imageWorkletRuntimeWeak, workletRuntimeGeneration]( + jsi::Runtime& runtime, const jsi::Value&, const jsi::Value* args, + size_t count) -> jsi::Value { if (count < 3 || !args[2].isObject() || !args[2].asObject(runtime).isFunction(runtime)) { return false; @@ -437,14 +1850,17 @@ void callImageLoadCallback( partialLoadBlock:^(UIImage*) { } completionBlock:^(NSError* error, UIImage* image) { - dispatch_async(dispatch_get_main_queue(), ^{ - UIImage* renderedImage = - imageWithRenderingMode(image, isTemplate); - callImageLoadCallback( - imageWorkletRuntimeWeak, callback, renderedImage, - error.localizedDescription); - }); - }]; + dispatch_async(dispatch_get_main_queue(), ^{ + UIImage* renderedImage = + imageWithRenderingMode(image, isTemplate); + callImageLoadCallback( + imageWorkletRuntimeWeak, + workletRuntimeGeneration, + callback, + renderedImage, + error.localizedDescription); + }); + }]; return true; }); workletRuntime.global().setProperty( diff --git a/packages/react-native/ios/NativeScriptUIKitHost.h b/packages/react-native/ios/NativeScriptUIKitHost.h index bec2d9dff..b9a142147 100644 --- a/packages/react-native/ios/NativeScriptUIKitHost.h +++ b/packages/react-native/ios/NativeScriptUIKitHost.h @@ -1,8 +1,47 @@ #import -FOUNDATION_EXPORT NSDictionary* NativeScriptCreateUIKitHost(NSString* hostId); +@class UIView; + +FOUNDATION_EXPORT NSDictionary* NativeScriptCreateUIKitHost( + NSString* hostId, NSString* propsJson); + +FOUNDATION_EXPORT NSDictionary* NativeScriptCreateUIKitHostWithInfo( + NSString* hostId, NSString* propsJson, NSString* nativeMountInfoJson); FOUNDATION_EXPORT NSDictionary* NativeScriptRunUIKitHostLifecycle( - NSString* hostId, NSString* phase); + NSString* hostId, NSString* phase, NSString* propsJson); + +FOUNDATION_EXPORT NSDictionary* NativeScriptRunUIKitHostLifecycleWithInfo( + NSString* hostId, + NSString* phase, + NSString* propsJson, + NSString* transactionJson, + NSString* nativeMountInfoJson); FOUNDATION_EXPORT BOOL NativeScriptRefreshUIKitHostView(NSString* viewHandle); + +FOUNDATION_EXPORT BOOL NativeScriptRefreshUIKitHostViewOwner(NSString* viewHandle); + +FOUNDATION_EXPORT BOOL NativeScriptRefreshUIKitHostViewDirectOwner(NSString* viewHandle); + +FOUNDATION_EXPORT BOOL NativeScriptInvalidateUIKitHostReadyOwner(NSString* viewHandle); + +FOUNDATION_EXPORT BOOL NativeScriptNotifyUIKitAccessibilityLayoutChanged(NSString* viewHandle); + +FOUNDATION_EXPORT BOOL NativeScriptFlushUIKitHostView(NSString* viewHandle); + +FOUNDATION_EXPORT BOOL NativeScriptFlushUIKitHostViewOwner(NSString* viewHandle); + +FOUNDATION_EXPORT NSDictionary* NativeScriptUIKitHostHandlesForView( + NSString* viewHandle); + +FOUNDATION_EXPORT NSDictionary* NativeScriptUIKitHostOwnerHandlesForView( + NSString* viewHandle); + +FOUNDATION_EXPORT NSString* NativeScriptNearestViewControllerForView(NSString* viewHandle); + +FOUNDATION_EXPORT BOOL NativeScriptAttachViewControllerToNearestParent( + NSString* controllerHandle, NSString* viewHandle, BOOL allowRootParent); + +FOUNDATION_EXPORT NSArray* NativeScriptCollectedUIKitHostChildren( + NSString* viewHandle); diff --git a/packages/react-native/ios/NativeScriptUIView.h b/packages/react-native/ios/NativeScriptUIView.h index 8293a0b60..aba700d92 100644 --- a/packages/react-native/ios/NativeScriptUIView.h +++ b/packages/react-native/ios/NativeScriptUIView.h @@ -15,14 +15,92 @@ @property(nonatomic, copy) NSString* nativeViewHandle; @property(nonatomic, copy) NSString* childrenViewHandle; @property(nonatomic, copy) NSString* controllerHandle; +@property(nonatomic, assign) BOOL attachNativeView; +@property(nonatomic, assign) BOOL attachControllerToParent; +@property(nonatomic, assign) BOOL adoptHostViewAsControllerView; +@property(nonatomic, assign) BOOL collectChildren; +@property(nonatomic, assign) BOOL detachControllerFromParent; @property(nonatomic, assign) BOOL detachControllerView; +@property(nonatomic, assign) BOOL disableDetachedChildrenTouchHandler; +@property(nonatomic, assign) BOOL disableUIKitHostWindowAttachRefresh; +@property(nonatomic, assign) BOOL emitOffWindowHostReady; +@property(nonatomic, assign) BOOL ignoreHostReadyWindowAttachment; +@property(nonatomic, assign) BOOL externalDetachedChildrenOwner; +@property(nonatomic, assign) BOOL fabricLifecycleCallbacks; +@property(nonatomic, assign) BOOL immediateTransactionCommit; +@property(nonatomic, assign) BOOL deferTransactionCommitOnRemovals; +@property(nonatomic, assign) BOOL mountChildrenDirectlyToChildrenView; +@property(nonatomic, assign) BOOL layoutDirectChildrenToChildrenViewBounds; +@property(nonatomic, assign) BOOL pinNativeViewToHost; +@property(nonatomic, assign) BOOL preserveDetachedChildrenLayout; +@property(nonatomic, assign) CGFloat detachedChildrenContentOffsetX; +@property(nonatomic, assign) CGFloat detachedChildrenContentOffsetY; @property(nonatomic, copy) NSString* debugName; +@property(nonatomic, copy) NSString* uikitHostPropsJson; +@property(nonatomic, assign) NSInteger uikitHostPropsRevision; @property(nonatomic, assign) NSInteger updateRevision; @property(nonatomic, assign) NSInteger mountedRevision; @property(nonatomic, copy) RCTDirectEventBlock onHostReady; @property(nonatomic, assign) id hostReadyDelegate; +@property(nonatomic, assign) UIView* fabricComponentView; - (void)layoutDetachedChildrenViewSubviewsIfNeeded; +- (BOOL)hostedContentPointInside:(CGPoint)point withEvent:(UIEvent*)event; +- (UIView*)hostedContentHitTest:(CGPoint)point withEvent:(UIEvent*)event; +- (BOOL)shouldHideEmptyFabricHostWrapper; +- (void)notifyFabricTransactionCommitted; +- (void)notifyFabricTransactionCommittedWithModifiedChildren:(BOOL)hasModifiedChildren + modifiedProps:(BOOL)hasModifiedProps; +- (void)notifyFabricTransactionCommittedWithModifiedChildren:(BOOL)hasModifiedChildren + modifiedProps:(BOOL)hasModifiedProps + mutations:(NSArray*>*)mutations; +// SEAM D STAGE 0 (Fabric transactionCommitted exactly-once dedup): a single +// delivery token owned by this host, shared by every producer that can +// schedule a deferred `transactionCommitted` -- ComponentView's +// mountingTransactionDidMount (the legitimate initiator) and mount-op +// fallback, plus this class's own props-revision path. It is bumped on every +// actual delivery (see notifyFabricTransactionCommittedWithModifiedChildren: +// modifiedProps:mutations:) and on setHostId:/dealloc. A producer that is +// about to schedule a dispatch_async delivery must call +// -advanceFabricTransactionDeliveryToken first (this both reserves a fresh +// token for its own deferred check AND immediately invalidates any +// still-pending schedule from another producer for the same commit); the +// deferred block then re-checks -fabricTransactionDeliveryToken when it runs +// and no-ops if it no longer matches -- i.e. some other producer (or a +// newer schedule, or a synchronous delivery) already handled this commit. +- (NSUInteger)fabricTransactionDeliveryToken; +- (NSUInteger)advanceFabricTransactionDeliveryToken; +- (NSArray*>*)fabricMountedChildrenSnapshot; - (BOOL)refreshDetachedChildrenHost; +- (void)mountUIKitHostIfNeeded; +- (NSDictionary*)uikitHostHandles; +- (NSArray*)collectedChildComponentViews; +- (void)recordFabricChildComponentViewMounted:(UIView*)view index:(NSInteger)index; +- (void)recordFabricChildComponentViewUnmounted:(UIView*)view; +- (void)clearFabricChildComponentViewRecords; +- (void)restoreFabricChildComponentViewsForUnmount:(UIView*)view index:(NSInteger)index; +// RNS `willBeUnmountedInUpcomingTransaction` parity: a Fabric-unmounted child +// must never be re-attached. `clearFabricRelocationRecordForUnmountedChildComponentView:` +// wraps the internal relocation-record clear for a child that just received +// its FINAL unmountChildComponentView detach. The pending-unmount-tag set is +// populated (from Remove/Delete mutations targeting this container) before a +// mounting transaction's unmounts run, and consulted while restoring +// relocated children so a sibling's unmount can never resurrect a view +// Fabric is deleting in the same transaction. +- (void)clearFabricRelocationRecordForUnmountedChildComponentView:(UIView*)view; +- (void)markFabricChildComponentViewTagsPendingUnmountForCurrentTransaction: + (NSSet*)tags; +- (void)clearFabricChildComponentViewTagsPendingUnmountForCurrentTransaction; +- (BOOL)unmountCollectedChildComponentView:(UIView*)view; +- (void)notifyFabricMountingTransactionWillMount; +- (void)notifyFabricChildMounted:(UIView*)componentView + childContainerView:(UIView*)childContainerView + index:(NSInteger)index; +- (void)notifyFabricChildUnmounted:(UIView*)componentView + childContainerView:(UIView*)childContainerView + index:(NSInteger)index; +- (BOOL)isAdoptedControllerViewOwnedByNavigationController; +- (BOOL)shouldDeferContainerFrameToNavigationController; +- (BOOL)containerFrameIsUIKitDrivenByNavigationController; @end diff --git a/packages/react-native/ios/NativeScriptUIView.mm b/packages/react-native/ios/NativeScriptUIView.mm index 444b1094e..9e14abce1 100644 --- a/packages/react-native/ios/NativeScriptUIView.mm +++ b/packages/react-native/ios/NativeScriptUIView.mm @@ -1,14 +1,20 @@ #import "NativeScriptUIView.h" #import "NativeScriptUIKitHost.h" +#import "Fabric/NativeScriptUIViewComponentView.h" +#import +#import #import #if __has_include() #import #endif - -#if __has_include() && __has_include() -#import -#import +#if __has_include() && \ + __has_include() +#import +#include +#define NATIVESCRIPT_RN_FABRIC_LAYOUT_METRICS_AVAILABLE 1 +#else +#define NATIVESCRIPT_RN_FABRIC_LAYOUT_METRICS_AVAILABLE 0 #endif static id NativeScriptNSObjectFromHandle(NSString* handle) { @@ -57,23 +63,107 @@ static id NativeScriptNSObjectFromHandle(NSString* handle) { return [NSString stringWithFormat:@"%p", object]; } -static BOOL NativeScriptChildrenViewHasVisibleChild(UIView* childrenView, UIView* sentinel) { - if (childrenView == nil) { - return NO; +static const void* NativeScriptFabricOriginalSuperviewKey = + &NativeScriptFabricOriginalSuperviewKey; +static const void* NativeScriptFabricOriginalIndexKey = + &NativeScriptFabricOriginalIndexKey; + +static void (*NativeScriptOriginalUIViewRemoveFromSuperview)(UIView*, SEL); +static void (*NativeScriptOriginalUIViewAddSubview)(UIView*, SEL, UIView*); +static void (*NativeScriptOriginalUIViewInsertSubviewAtIndex)(UIView*, SEL, UIView*, NSInteger); +static void (*NativeScriptOriginalUIViewInsertSubviewAboveSubview)(UIView*, SEL, UIView*, UIView*); +static void (*NativeScriptOriginalUIViewInsertSubviewBelowSubview)(UIView*, SEL, UIView*, UIView*); +static void (*NativeScriptOriginalRCTViewComponentViewUnmountChild)(id, SEL, UIView*, NSInteger); +static NSUInteger NativeScriptFabricTopologyRestoreDepth; + +static NSHashTable* NativeScriptRelocatedFabricChildrenTable() { + static NSHashTable* children; + if (children == nil) { + children = [[NSHashTable alloc] initWithOptions:NSPointerFunctionsWeakMemory capacity:0]; } + return children; +} - for (UIView* subview in childrenView.subviews) { - if (subview == sentinel || subview.hidden || subview.alpha <= 0.01) { - continue; +static BOOL NativeScriptViewConformsToRCTComponentViewProtocol(UIView* view) { + static Protocol* componentViewProtocol; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + componentViewProtocol = NSProtocolFromString(@"RCTComponentViewProtocol"); + }); + return view != nil && componentViewProtocol != nil && + [view conformsToProtocol:componentViewProtocol]; +} + +static UIView* NativeScriptCurrentContainerViewForComponentView(UIView* view) { + if (view == nil) { + return nil; + } + + SEL nativeScriptSelector = NSSelectorFromString(@"nativeScriptCurrentContainerView"); + if ([view respondsToSelector:nativeScriptSelector]) { + IMP implementation = [view methodForSelector:nativeScriptSelector]; + if (implementation != nullptr) { + UIView* (*nativeScriptCurrentContainerView)(id, SEL) = + reinterpret_cast(implementation); + UIView* containerView = nativeScriptCurrentContainerView(view, nativeScriptSelector); + if (containerView != nil) { + return containerView; + } } + } + SEL selector = NSSelectorFromString(@"currentContainerView"); + if (![view respondsToSelector:selector]) { + return view; + } + + IMP implementation = [view methodForSelector:selector]; + if (implementation == nullptr) { + return view; + } + + UIView* (*currentContainerView)(id, SEL) = + reinterpret_cast(implementation); + UIView* containerView = currentContainerView(view, selector); + return containerView ?: view; +} + +static BOOL NativeScriptSuperviewIsFabricComponentContainer(UIView* superview) { + if (superview == nil) { + return NO; + } + + if (NativeScriptViewConformsToRCTComponentViewProtocol(superview)) { return YES; } + UIView* current = superview.superview; + NSUInteger depth = 0; + while (current != nil && depth < 4) { + if (NativeScriptViewConformsToRCTComponentViewProtocol(current) && + NativeScriptCurrentContainerViewForComponentView(current) == superview) { + return YES; + } + current = current.superview; + depth += 1; + } + return NO; } -static UIViewController* NativeScriptNearestViewController(UIView* view) { +static UIView* NativeScriptOriginalFabricSuperviewForView(UIView* view) { + NSValue* superview = + static_cast(objc_getAssociatedObject(view, NativeScriptFabricOriginalSuperviewKey)); + return superview == nil ? nil : static_cast(superview.nonretainedObjectValue); +} + +static NSUInteger NativeScriptOriginalFabricIndexForView(UIView* view) { + NSNumber* index = + static_cast(objc_getAssociatedObject(view, NativeScriptFabricOriginalIndexKey)); + return index == nil ? NSNotFound : index.unsignedIntegerValue; +} + +static UIViewController* NativeScriptFabricResponderControllerForView(UIView* view) { UIResponder* responder = view; while (responder != nil) { responder = responder.nextResponder; @@ -81,903 +171,4586 @@ static BOOL NativeScriptChildrenViewHasVisibleChild(UIView* childrenView, UIView return static_cast(responder); } } + return nil; } -static BOOL NativeScriptViewIsDescendantOfView(UIView* view, UIView* ancestor) { - UIView* current = view; - while (current != nil) { - if (current == ancestor) { +static BOOL NativeScriptFabricControllerIsTransitioning(UIViewController* controller) { + UIViewController* current = controller; + NSUInteger depth = 0; + while (current != nil && depth < 16) { + if (current.transitionCoordinator != nil || current.isBeingPresented || + current.isBeingDismissed || current.isMovingToParentViewController || + current.isMovingFromParentViewController) { return YES; } - current = current.superview; + + UINavigationController* navigationController = current.navigationController; + if (navigationController != nil && + (navigationController.transitionCoordinator != nil || + navigationController.isBeingPresented || navigationController.isBeingDismissed || + navigationController.isMovingToParentViewController || + navigationController.isMovingFromParentViewController)) { + return YES; + } + + UITabBarController* tabBarController = current.tabBarController; + if (tabBarController != nil && + (tabBarController.transitionCoordinator != nil || tabBarController.isBeingPresented || + tabBarController.isBeingDismissed || tabBarController.isMovingToParentViewController || + tabBarController.isMovingFromParentViewController)) { + return YES; + } + + current = current.parentViewController; + depth += 1; } + return NO; } -static BOOL NativeScriptViewHasGestureRecognizer(UIView* view, UIGestureRecognizer* recognizer) { - if (view == nil || recognizer == nil) { +static BOOL NativeScriptFabricRestoreWouldCrossActiveControllerTransition(UIView* child, + UIView* superview) { + if (child == nil || superview == nil || child.superview == superview) { return NO; } - for (UIGestureRecognizer* existingRecognizer in view.gestureRecognizers) { - if (existingRecognizer == recognizer) { - return YES; - } + UIViewController* childController = NativeScriptFabricResponderControllerForView(child); + UIViewController* targetController = NativeScriptFabricResponderControllerForView(superview); + if (childController == nil || targetController == nil || childController == targetController) { + return NO; } - return NO; + if (child.window != nil && superview.window != nil && child.window != superview.window) { + return YES; + } + + return NativeScriptFabricControllerIsTransitioning(childController) || + NativeScriptFabricControllerIsTransitioning(targetController); } -static UIView* NativeScriptGestureRecognizerAttachedView(id recognizer) { - if (recognizer == nil || ![recognizer isKindOfClass:UIGestureRecognizer.class]) { - return nil; +static void NativeScriptClearFabricRelocationRecord(UIView* view) { + if (view == nil) { + return; } - return static_cast(recognizer).view; + objc_setAssociatedObject(view, NativeScriptFabricOriginalSuperviewKey, nil, + OBJC_ASSOCIATION_ASSIGN); + objc_setAssociatedObject(view, NativeScriptFabricOriginalIndexKey, nil, + OBJC_ASSOCIATION_ASSIGN); + [NativeScriptRelocatedFabricChildrenTable() removeObject:view]; } -static UIGestureRecognizer* NativeScriptFindAncestorSurfaceTouchHandler(UIView* view) { -#if __has_include() - UIView* parent = view.superview; - NSUInteger depth = 0; +static void NativeScriptClearFabricRelocationRecordIfRestored(UIView* view) { + UIView* originalSuperview = NativeScriptOriginalFabricSuperviewForView(view); + if (originalSuperview == nil || view.superview != originalSuperview) { + return; + } - while (parent != nil && depth < 32) { - for (UIGestureRecognizer* recognizer in parent.gestureRecognizers) { - if ([recognizer isKindOfClass:RCTSurfaceTouchHandler.class]) { - return recognizer; - } - } + NSUInteger expectedIndex = NativeScriptOriginalFabricIndexForView(view); + NSUInteger actualIndex = [originalSuperview.subviews indexOfObject:view]; + if (expectedIndex == NSNotFound || actualIndex == expectedIndex) { + NativeScriptClearFabricRelocationRecord(view); + } +} - parent = parent.superview; - depth += 1; +static void NativeScriptRecordFabricParentBeforeMove(UIView* view) { + if (view == nil || NativeScriptFabricTopologyRestoreDepth > 0 || + !NativeScriptViewConformsToRCTComponentViewProtocol(view)) { + return; } -#endif - return nil; -} + UIView* existingOriginalSuperview = NativeScriptOriginalFabricSuperviewForView(view); + if (existingOriginalSuperview != nil) { + NativeScriptClearFabricRelocationRecordIfRestored(view); + return; + } -static BOOL NativeScriptShouldForwardControllerAppearance(UIViewController* controller) { - return controller != nil && controller.view != nil && controller.view.window != nil; -} + UIView* originalSuperview = view.superview; + if (!NativeScriptSuperviewIsFabricComponentContainer(originalSuperview)) { + return; + } -static BOOL NativeScriptHostedViewContainsControllerView(UIView* hostedView, - UIViewController* controller) { - return hostedView != nil && controller != nil && controller.view != nil && - NativeScriptViewIsDescendantOfView(controller.view, hostedView); + NSUInteger originalIndex = [originalSuperview.subviews indexOfObject:view]; + if (originalIndex == NSNotFound) { + return; + } + + objc_setAssociatedObject(view, NativeScriptFabricOriginalSuperviewKey, + [NSValue valueWithNonretainedObject:originalSuperview], + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + objc_setAssociatedObject(view, NativeScriptFabricOriginalIndexKey, @(originalIndex), + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + [NativeScriptRelocatedFabricChildrenTable() addObject:view]; } -static CGRect NativeScriptEffectiveTabBarHitBounds(UITabBar* tabBar) { - CGRect bounds = tabBar.bounds; - CGSize fittingSize = [tabBar sizeThatFits:CGSizeMake(bounds.size.width, bounds.size.height)]; - CGFloat maximumHeight = MAX(fittingSize.height + 32, 96); +static void NativeScriptFabricGuardRemoveFromSuperview(UIView* view, SEL selector) { + NativeScriptRecordFabricParentBeforeMove(view); + NativeScriptOriginalUIViewRemoveFromSuperview(view, selector); + NativeScriptClearFabricRelocationRecordIfRestored(view); +} - if (bounds.size.height > maximumHeight) { - bounds.origin.y = CGRectGetMaxY(bounds) - maximumHeight; - bounds.size.height = maximumHeight; - } +static void NativeScriptFabricGuardAddSubview(UIView* view, + SEL selector, + UIView* subview) { + NativeScriptRecordFabricParentBeforeMove(subview); + NativeScriptOriginalUIViewAddSubview(view, selector, subview); + NativeScriptClearFabricRelocationRecordIfRestored(subview); +} - return CGRectInset(bounds, -24, -16); +static void NativeScriptFabricGuardInsertSubviewAtIndex(UIView* view, + SEL selector, + UIView* subview, + NSInteger index) { + NativeScriptRecordFabricParentBeforeMove(subview); + NativeScriptOriginalUIViewInsertSubviewAtIndex(view, selector, subview, index); + NativeScriptClearFabricRelocationRecordIfRestored(subview); } -static BOOL NativeScriptPointInsideTabBarHitArea(UITabBar* tabBar, UIWindow* window, - CGPoint windowPoint) { - if (tabBar == nil || tabBar.hidden || tabBar.alpha <= 0.01 || - !tabBar.userInteractionEnabled) { - return NO; - } +static void NativeScriptFabricGuardInsertSubviewAboveSubview(UIView* view, + SEL selector, + UIView* subview, + UIView* siblingSubview) { + NativeScriptRecordFabricParentBeforeMove(subview); + NativeScriptOriginalUIViewInsertSubviewAboveSubview(view, selector, subview, siblingSubview); + NativeScriptClearFabricRelocationRecordIfRestored(subview); +} - CGPoint localPoint = [tabBar convertPoint:windowPoint fromView:window]; - return CGRectContainsPoint(NativeScriptEffectiveTabBarHitBounds(tabBar), localPoint); +static void NativeScriptFabricGuardInsertSubviewBelowSubview(UIView* view, + SEL selector, + UIView* subview, + UIView* siblingSubview) { + NativeScriptRecordFabricParentBeforeMove(subview); + NativeScriptOriginalUIViewInsertSubviewBelowSubview(view, selector, subview, siblingSubview); + NativeScriptClearFabricRelocationRecordIfRestored(subview); } -static UITabBar* NativeScriptVisibleTabBarAtPoint(UIView* root, UIWindow* window, - CGPoint windowPoint) { - if (root.hidden || root.alpha <= 0.01 || !root.userInteractionEnabled) { - return nil; +static NSArray* NativeScriptRelocatedFabricChildrenForSuperview(UIView* superview) { + if (superview == nil) { + return @[]; } - if ([root isKindOfClass:UITabBar.class]) { - UITabBar* tabBar = static_cast(root); - if (NativeScriptPointInsideTabBarHitArea(tabBar, window, windowPoint)) { - return static_cast(root); + NSMutableArray* children = [NSMutableArray array]; + for (UIView* child in NativeScriptRelocatedFabricChildrenTable()) { + if (NativeScriptOriginalFabricSuperviewForView(child) == superview) { + [children addObject:child]; } } - for (UIView* subview in [root.subviews reverseObjectEnumerator]) { - UITabBar* tabBar = NativeScriptVisibleTabBarAtPoint(subview, window, windowPoint); - if (tabBar != nil) { - return tabBar; + [children sortUsingComparator:^NSComparisonResult(UIView* left, UIView* right) { + NSUInteger leftIndex = NativeScriptOriginalFabricIndexForView(left); + NSUInteger rightIndex = NativeScriptOriginalFabricIndexForView(right); + if (leftIndex < rightIndex) { + return NSOrderedAscending; } - } - - return nil; + if (leftIndex > rightIndex) { + return NSOrderedDescending; + } + return NSOrderedSame; + }]; + return children; } -static BOOL NativeScriptSubviewShouldFillParent(UIView* parent, UIView* child) { - if (parent == nil || child == nil) { +static BOOL NativeScriptRestoreFabricChildToSuperviewAtIndex(UIView* child, + UIView* superview, + NSUInteger index) { + if (child == nil || superview == nil) { return NO; } - const CGRect parentBounds = parent.bounds; - const CGRect childFrame = child.frame; - if (parentBounds.size.width <= 0) { + if (NativeScriptFabricRestoreWouldCrossActiveControllerTransition(child, superview)) { return NO; } - return fabs(childFrame.origin.x) < 1 && fabs(childFrame.origin.y) < 1 && - (childFrame.size.width <= 0 || fabs(childFrame.size.width - parentBounds.size.width) < 2); + [child retain]; + NativeScriptFabricTopologyRestoreDepth += 1; + @try { + NSUInteger targetIndex = MIN(index, superview.subviews.count); + [superview insertSubview:child atIndex:targetIndex]; + } @finally { + NativeScriptFabricTopologyRestoreDepth -= 1; + [child release]; + } + return YES; } -static void NativeScriptLayoutHostedSubviewChain(UIView* root, NSUInteger depth) { - if (root == nil || depth > 12 || [root isKindOfClass:UIScrollView.class]) { - return; +static BOOL NativeScriptRestoreFabricChildrenForUnmount(UIView* expectedSuperview, + UIView* child, + NSInteger index, + NSSet* pendingUnmountTags, + NSArray* mountedChildLedger) { + if (expectedSuperview == nil) { + return NO; } - const CGRect bounds = root.bounds; - for (UIView* subview in root.subviews) { - if (!NativeScriptSubviewShouldFillParent(root, subview)) { + BOOL shouldHandleUnmountInRuntime = NO; + NSArray* relocatedChildren = + NativeScriptRelocatedFabricChildrenForSuperview(expectedSuperview); + for (UIView* relocatedChild in relocatedChildren) { + NSUInteger expectedIndex = NativeScriptOriginalFabricIndexForView(relocatedChild); + if (expectedIndex == NSNotFound) { continue; } - subview.frame = bounds; - subview.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; - [subview setNeedsLayout]; - [subview layoutIfNeeded]; - NativeScriptLayoutHostedSubviewChain(subview, depth + 1); + // RNS invariant: a view Fabric has unmounted (or is unmounting in THIS + // transaction) may never be re-attached. `pendingUnmountTags` covers the + // case where the relocation record is created mid-transaction (a sibling + // unmount's synchronous reconcile reparenting this child) after Fabric + // already resolved this container's Remove/Delete mutations for it; + // `mountedChildLedger` catches the same invariant reactively for any + // relocated child no longer tracked as mounted (already unmounted). + if ([pendingUnmountTags containsObject:@(relocatedChild.tag)] || + (mountedChildLedger != nil && + ![mountedChildLedger containsObject:relocatedChild])) { + continue; + } + + if (NativeScriptRestoreFabricChildToSuperviewAtIndex(relocatedChild, expectedSuperview, + expectedIndex)) { + NativeScriptClearFabricRelocationRecordIfRestored(relocatedChild); + } else if (relocatedChild == child) { + shouldHandleUnmountInRuntime = YES; + } } -} -@class NativeScriptUIView; + if (child == nil || child.superview != expectedSuperview || index < 0) { + return shouldHandleUnmountInRuntime; + } -static const void* NativeScriptDetachedChildrenOwnerKey = - &NativeScriptDetachedChildrenOwnerKey; + NSUInteger actualIndex = [expectedSuperview.subviews indexOfObject:child]; + NSUInteger expectedIndex = static_cast(index); + if (actualIndex != NSNotFound && actualIndex != expectedIndex && + expectedIndex <= expectedSuperview.subviews.count) { + if (NativeScriptRestoreFabricChildToSuperviewAtIndex(child, expectedSuperview, + expectedIndex)) { + NativeScriptClearFabricRelocationRecordIfRestored(child); + } else { + shouldHandleUnmountInRuntime = YES; + } + } + return shouldHandleUnmountInRuntime; +} -static NativeScriptUIView* NativeScriptDetachedChildrenOwner(UIView* view) { - id owner = view == nil ? nil : objc_getAssociatedObject(view, NativeScriptDetachedChildrenOwnerKey); - if (owner == nil || ![owner isKindOfClass:NativeScriptUIView.class]) { - return nil; +static void NativeScriptFabricUnmountRelocatedChildInRuntime(UIView* child) { + if (child == nil) { + return; } - return static_cast(owner); + [child retain]; + if (child.superview != nil) { + if (NativeScriptOriginalUIViewRemoveFromSuperview != nullptr) { + NativeScriptOriginalUIViewRemoveFromSuperview(child, @selector(removeFromSuperview)); + } else { + [child removeFromSuperview]; + } + } + NativeScriptClearFabricRelocationRecord(child); + [child release]; } -static void NativeScriptSetDetachedChildrenOwner(UIView* view, NativeScriptUIView* owner) { - if (view == nil) { +static void NativeScriptFabricGuardRCTViewComponentViewUnmountChild(id parent, + SEL selector, + UIView* child, + NSInteger index) { + UIView* expectedSuperview = + NativeScriptCurrentContainerViewForComponentView(static_cast(parent)); + // Generic plain-RCTComponentView unmount funnel: no per-container + // pending-unmount-tag/ledger tracking exists here (that is NativeScriptUIView- + // specific state), so pass nil for both — behavior is unchanged from before. + if (NativeScriptRestoreFabricChildrenForUnmount(expectedSuperview, child, index, nil, nil)) { + NativeScriptFabricUnmountRelocatedChildInRuntime(child); return; } - - objc_setAssociatedObject( - view, NativeScriptDetachedChildrenOwnerKey, owner, OBJC_ASSOCIATION_ASSIGN); + NativeScriptOriginalRCTViewComponentViewUnmountChild(parent, selector, child, index); + NativeScriptClearFabricRelocationRecord(child); } -@interface NativeScriptUIView () -- (void)attachDetachedChildrenTouchHandlerIfNeeded; -- (void)installDetachedChildrenTouchSentinelIfNeeded; -- (void)notifyHostReadyIfNeeded; -- (BOOL)refreshDetachedChildrenHost; -- (void)updateDetachedChildrenTouchHandlerOrigin; -@end +static void NativeScriptInstallFabricReparentingGuard() { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + Method removeMethod = class_getInstanceMethod(UIView.class, @selector(removeFromSuperview)); + if (removeMethod != nullptr) { + NativeScriptOriginalUIViewRemoveFromSuperview = + reinterpret_cast(method_getImplementation(removeMethod)); + method_setImplementation(removeMethod, + reinterpret_cast(NativeScriptFabricGuardRemoveFromSuperview)); + } -@interface NativeScriptDetachedChildrenTouchSentinel : UIView -@property(nonatomic, assign) NativeScriptUIView* owner; -@end + Method addMethod = class_getInstanceMethod(UIView.class, @selector(addSubview:)); + if (addMethod != nullptr) { + NativeScriptOriginalUIViewAddSubview = + reinterpret_cast(method_getImplementation(addMethod)); + method_setImplementation(addMethod, reinterpret_cast(NativeScriptFabricGuardAddSubview)); + } -@implementation NativeScriptDetachedChildrenTouchSentinel + Method insertAtIndexMethod = + class_getInstanceMethod(UIView.class, @selector(insertSubview:atIndex:)); + if (insertAtIndexMethod != nullptr) { + NativeScriptOriginalUIViewInsertSubviewAtIndex = + reinterpret_cast( + method_getImplementation(insertAtIndexMethod)); + method_setImplementation( + insertAtIndexMethod, + reinterpret_cast(NativeScriptFabricGuardInsertSubviewAtIndex)); + } -- (void)didMoveToWindow { - [super didMoveToWindow]; - [self.owner refreshDetachedChildrenHost]; -} + Method insertAboveMethod = + class_getInstanceMethod(UIView.class, @selector(insertSubview:aboveSubview:)); + if (insertAboveMethod != nullptr) { + NativeScriptOriginalUIViewInsertSubviewAboveSubview = + reinterpret_cast( + method_getImplementation(insertAboveMethod)); + method_setImplementation( + insertAboveMethod, + reinterpret_cast(NativeScriptFabricGuardInsertSubviewAboveSubview)); + } -- (void)didMoveToSuperview { - [super didMoveToSuperview]; - [self.owner refreshDetachedChildrenHost]; -} + Method insertBelowMethod = + class_getInstanceMethod(UIView.class, @selector(insertSubview:belowSubview:)); + if (insertBelowMethod != nullptr) { + NativeScriptOriginalUIViewInsertSubviewBelowSubview = + reinterpret_cast( + method_getImplementation(insertBelowMethod)); + method_setImplementation( + insertBelowMethod, + reinterpret_cast(NativeScriptFabricGuardInsertSubviewBelowSubview)); + } -- (void)layoutSubviews { - [super layoutSubviews]; - [self.owner refreshDetachedChildrenHost]; + Class rctViewComponentView = NSClassFromString(@"RCTViewComponentView"); + SEL unmountSelector = NSSelectorFromString(@"unmountChildComponentView:index:"); + Method unmountMethod = class_getInstanceMethod(rctViewComponentView, unmountSelector); + if (unmountMethod != nullptr) { + NativeScriptOriginalRCTViewComponentViewUnmountChild = + reinterpret_cast( + method_getImplementation(unmountMethod)); + method_setImplementation( + unmountMethod, + reinterpret_cast(NativeScriptFabricGuardRCTViewComponentViewUnmountChild)); + } + }); } -@end +static BOOL NativeScriptChildrenViewHasVisibleChild(UIView* childrenView, + UIView* sentinel, + UIView* owner) { + if (childrenView == nil) { + return NO; + } -@implementation NativeScriptUIView { - UIView* _nativeView; - UIView* _childrenView; - UIViewController* _viewController; - id _detachedTouchHandler; - UIView* _detachedTouchHandlerView; - UIWindow* _detachedTouchHandlerWindow; - NativeScriptDetachedChildrenTouchSentinel* _detachedTouchSentinel; - NSInteger _hostMountRetryCount; - NSString* _lastHostReadyKey; -} + for (UIView* subview in childrenView.subviews) { + if (subview == sentinel || subview.hidden || subview.alpha <= 0.01) { + continue; + } + if (subview == owner) { + if (NativeScriptChildrenViewHasVisibleChild(subview, sentinel, owner)) { + return YES; + } + continue; + } -- (void)dealloc { - if (_hostId.length > 0) { - NativeScriptRunUIKitHostLifecycle(_hostId, @"dispose"); - } - [self detachViewController]; - [self detachDetachedChildrenTouchHandler]; - [_detachedTouchSentinel removeFromSuperview]; - [_detachedTouchSentinel release]; - [_nativeView removeFromSuperview]; - [_nativeView release]; - if (NativeScriptDetachedChildrenOwner(_childrenView) == self) { - NativeScriptSetDetachedChildrenOwner(_childrenView, nil); + return YES; } - [_childrenView release]; - [_viewController release]; - [_detachedTouchHandler release]; - [_detachedTouchHandlerView release]; - [_nativeViewHandle release]; - [_childrenViewHandle release]; - [_controllerHandle release]; - [_hostId release]; - [_hostReadyId release]; - [_debugName release]; - [_onHostReady release]; - [_lastHostReadyKey release]; - [super dealloc]; + + return NO; } -- (void)setHostId:(NSString*)hostId { - if ((_hostId == hostId) || [_hostId isEqualToString:hostId]) { - return; +static NSUInteger NativeScriptVisibleDescendantCount(UIView* view, + UIView* sentinel, + UIView* owner, + NSUInteger depth) { + if (view == nil || depth > 32 || view.hidden || view.alpha <= 0.01) { + return 0; } - NSString* previousHostId = [_hostId copy]; - if (previousHostId.length > 0) { - NativeScriptRunUIKitHostLifecycle(previousHostId, @"dispose"); + NSUInteger count = (view == sentinel || view == owner) ? 0 : 1; + for (UIView* subview in view.subviews) { + count += NativeScriptVisibleDescendantCount(subview, sentinel, owner, depth + 1); } - [previousHostId release]; - [_hostId release]; - _hostId = [hostId copy]; - _hostMountRetryCount = 0; - [_lastHostReadyKey release]; - _lastHostReadyKey = nil; - [self mountUIKitHostIfNeeded]; - [self notifyHostReadyIfNeeded]; + return count; } -- (void)setHostReadyId:(NSString*)hostReadyId { - if ((_hostReadyId == hostReadyId) || [_hostReadyId isEqualToString:hostReadyId]) { - return; +static NSUInteger NativeScriptChildrenViewVisibleDescendantCount(UIView* childrenView, + UIView* sentinel, + UIView* owner) { + if (childrenView == nil) { + return 0; } - [_hostReadyId release]; - _hostReadyId = [hostReadyId copy]; - [_lastHostReadyKey release]; - _lastHostReadyKey = nil; - [self notifyHostReadyIfNeeded]; -} - -- (void)setOnHostReady:(RCTDirectEventBlock)onHostReady { - if (_onHostReady == onHostReady) { - return; + NSUInteger count = 0; + for (UIView* subview in childrenView.subviews) { + count += NativeScriptVisibleDescendantCount(subview, sentinel, owner, 0); } - [_onHostReady release]; - _onHostReady = [onHostReady copy]; - [self notifyHostReadyIfNeeded]; + return count; } -- (void)setNativeViewHandle:(NSString*)nativeViewHandle { - if ((_nativeViewHandle == nativeViewHandle) || - [_nativeViewHandle isEqualToString:nativeViewHandle]) { - return; +static UIViewController* NativeScriptTopMostViewControllerForWindow(UIView* view) { + UIViewController* controller = view.window.rootViewController; + while (controller.presentedViewController != nil && + !controller.presentedViewController.isBeingDismissed) { + controller = controller.presentedViewController; } + return controller; +} - [_nativeViewHandle release]; - _nativeViewHandle = [nativeViewHandle copy]; - UIView* nativeView = NativeScriptUIViewFromHandle(_nativeViewHandle); - if (_detachControllerView && _viewController != nil && nativeView == _viewController.view) { - nativeView = nil; - } - if (nativeView == nil && _nativeViewHandle.length == 0 && !_detachControllerView && - _viewController != nil) { - nativeView = _viewController.view; +static UIViewController* NativeScriptNearestViewController(UIView* view, UIViewController* excludedController) { + UIResponder* responder = view; + while (responder != nil) { + responder = responder.nextResponder; + if ([responder isKindOfClass:UIViewController.class] && + responder != excludedController) { + return static_cast(responder); + } } - [self setNativeView:nativeView]; + + UIViewController* controller = NativeScriptTopMostViewControllerForWindow(view); + return controller == excludedController ? nil : controller; } -- (void)setChildrenViewHandle:(NSString*)childrenViewHandle { - if ((_childrenViewHandle == childrenViewHandle) || - [_childrenViewHandle isEqualToString:childrenViewHandle]) { - return; +static UIViewController* NativeScriptNearestResponderViewController(UIView* view, + UIViewController* excludedController) { + UIResponder* responder = view; + while (responder != nil) { + responder = responder.nextResponder; + if ([responder isKindOfClass:UIViewController.class] && + responder != excludedController) { + return static_cast(responder); + } } - [_childrenViewHandle release]; - _childrenViewHandle = [childrenViewHandle copy]; - [self setChildrenView:NativeScriptUIViewFromHandle(_childrenViewHandle)]; + return nil; } -- (void)setControllerHandle:(NSString*)controllerHandle { - if ((_controllerHandle == controllerHandle) || - [_controllerHandle isEqualToString:controllerHandle]) { - return; +static UIViewController* NativeScriptReactViewControllerForView(UIView* view) { + if (view == nil) { + return nil; } - [_controllerHandle release]; - _controllerHandle = [controllerHandle copy]; - [self setViewController:NativeScriptUIViewControllerFromHandle(_controllerHandle)]; + UIViewController* controller = view.reactViewController; + return controller; } -- (void)setDetachControllerView:(BOOL)detachControllerView { - if (_detachControllerView == detachControllerView) { - return; +static UIView* NativeScriptReactSuperviewForView(UIView* view) { + if (view == nil) { + return nil; } - if (detachControllerView) { - [self detachViewController]; - if (_viewController != nil && _nativeView == _viewController.view) { - [self setNativeView:nil]; - } + return view.reactSuperview ?: view.superview; +} + +static UIViewController* NativeScriptClosestReactViewControllerForView(UIView* view, + UIViewController* excludedController) { + UIViewController* controller = NativeScriptReactViewControllerForView(view); + if (controller != nil && controller != excludedController) { + return controller; } - _detachControllerView = detachControllerView; + UIView* parentView = NativeScriptReactSuperviewForView(view); + NSUInteger depth = 0; + while (parentView != nil && parentView != view && depth < 64) { + controller = NativeScriptReactViewControllerForView(parentView); + if (controller != nil && controller != excludedController) { + return controller; + } - if (!_detachControllerView && _viewController != nil) { - if (_nativeViewHandle.length == 0) { - [self setNativeView:_viewController.view]; + UIView* nextParentView = NativeScriptReactSuperviewForView(parentView); + if (nextParentView == parentView) { + break; } - [self attachViewControllerIfPossible]; + + parentView = nextParentView; + depth += 1; } + + return nil; } -- (void)setDebugName:(NSString*)debugName { - if ((_debugName == debugName) || [_debugName isEqualToString:debugName]) { - return; +static BOOL NativeScriptControllerHierarchyContainsController(UIViewController* rootController, + UIViewController* controller, + NSUInteger depth) { + if (rootController == nil || controller == nil || depth > 32) { + return NO; } - [_debugName release]; - _debugName = [debugName copy]; -} + if (rootController == controller) { + return YES; + } -- (void)setUpdateRevision:(NSInteger)updateRevision { - if (_updateRevision == updateRevision) { - return; + if ([rootController isKindOfClass:UINavigationController.class]) { + for (UIViewController* child in static_cast(rootController).viewControllers) { + if (NativeScriptControllerHierarchyContainsController(child, controller, depth + 1)) { + return YES; + } + } } - _updateRevision = updateRevision; - if (_updateRevision > 0) { - [self runUIKitHostLifecycle:@"update"]; + if ([rootController isKindOfClass:UITabBarController.class]) { + for (UIViewController* child in static_cast(rootController).viewControllers) { + if (NativeScriptControllerHierarchyContainsController(child, controller, depth + 1)) { + return YES; + } + } + } + + if ([rootController isKindOfClass:UISplitViewController.class]) { + for (UIViewController* child in static_cast(rootController).viewControllers) { + if (NativeScriptControllerHierarchyContainsController(child, controller, depth + 1)) { + return YES; + } + } + } + + for (UIViewController* child in rootController.childViewControllers) { + if (NativeScriptControllerHierarchyContainsController(child, controller, depth + 1)) { + return YES; + } } + + return NativeScriptControllerHierarchyContainsController( + rootController.presentedViewController, controller, depth + 1); } -- (void)setMountedRevision:(NSInteger)mountedRevision { - if (_mountedRevision == mountedRevision) { - return; +static BOOL NativeScriptControllerHierarchyContainsController(UIViewController* rootController, + UIViewController* controller) { + return NativeScriptControllerHierarchyContainsController(rootController, controller, 0); +} + +static BOOL NativeScriptViewIsDescendantOfView(UIView* view, UIView* ancestor) { + UIView* current = view; + while (current != nil) { + if (current == ancestor) { + return YES; + } + current = current.superview; } + return NO; +} - _mountedRevision = mountedRevision; - if (_mountedRevision > 0) { - [self runUIKitHostLifecycle:@"mounted"]; +static BOOL NativeScriptViewHasHiddenUIKitAncestor(UIView* view) { + UIView* current = view; + while (current != nil) { + if (current.hidden || current.alpha <= 0.01 || current.accessibilityElementsHidden) { + return YES; + } + current = current.superview; } + return NO; } -- (NSString*)description { - if (_debugName.length == 0) { - return [super description]; +static BOOL NativeScriptViewHasGestureRecognizer(UIView* view, UIGestureRecognizer* recognizer) { + if (view == nil || recognizer == nil) { + return NO; } - NSString* description = [super description]; - if ([description hasSuffix:@">"]) { - return [[description substringToIndex:description.length - 1] - stringByAppendingFormat:@"; debugName = %@>", _debugName]; + for (UIGestureRecognizer* existingRecognizer in view.gestureRecognizers) { + if (existingRecognizer == recognizer) { + return YES; + } } - return [description stringByAppendingFormat:@" debugName = %@", _debugName]; + + return NO; } -- (NSDictionary*)hostReadyEventWithHasChildren:(BOOL)hasChildren { - NSString* readyId = _hostReadyId.length > 0 ? _hostReadyId : _hostId; - if (readyId.length == 0) { +static UIView* NativeScriptGestureRecognizerAttachedView(id recognizer) { + if (recognizer == nil || ![recognizer isKindOfClass:UIGestureRecognizer.class]) { return nil; } - NSMutableDictionary* event = [NSMutableDictionary dictionaryWithCapacity:6]; - event[@"hostReadyId"] = readyId; - event[@"hostId"] = _hostId ?: @""; - event[@"nativeViewHandle"] = NativeScriptHandleFromNSObject(_nativeView); - event[@"childrenViewHandle"] = NativeScriptHandleFromNSObject(_childrenView); - event[@"controllerHandle"] = NativeScriptHandleFromNSObject(_viewController); - event[@"hasChildren"] = @(hasChildren); - return event; + return static_cast(recognizer).view; } -- (void)notifyHostReadyIfNeeded { - const BOOL hasChildren = - NativeScriptChildrenViewHasVisibleChild(_childrenView, _detachedTouchSentinel); - if (!hasChildren) { - return; +static BOOL NativeScriptGestureRecognizerHasActiveTouches(id recognizer) { + if (recognizer == nil || ![recognizer isKindOfClass:UIGestureRecognizer.class]) { + return NO; } - NSDictionary* event = [self hostReadyEventWithHasChildren:hasChildren]; - if (event == nil) { - return; + UIGestureRecognizer* gesture = static_cast(recognizer); + if (gesture.state == UIGestureRecognizerStateBegan || + gesture.state == UIGestureRecognizerStateChanged) { + return YES; } - NSString* key = [NSString - stringWithFormat:@"%@|%@|%@|%@|%@|%@", - event[@"hostReadyId"] ?: @"", - event[@"hostId"] ?: @"", - event[@"nativeViewHandle"] ?: @"", - event[@"childrenViewHandle"] ?: @"", - event[@"controllerHandle"] ?: @"", - [event[@"hasChildren"] boolValue] ? @"1" : @"0"]; - if ([_lastHostReadyKey isEqualToString:key]) { - return; - } + return gesture.state == UIGestureRecognizerStatePossible && gesture.numberOfTouches > 0; +} - [_lastHostReadyKey release]; - _lastHostReadyKey = [key copy]; +static BOOL NativeScriptTouchDebugEnabled() { + const char* enabled = getenv("NS_NS_TOUCH_DEBUG"); + return enabled != nullptr && enabled[0] == '1'; +} - if (_onHostReady != nil) { - _onHostReady(event); - } - if ([_hostReadyDelegate respondsToSelector:@selector(nativeScriptUIView:didHostReady:)]) { - [_hostReadyDelegate nativeScriptUIView:self didHostReady:event]; - } +static BOOL NativeScriptFabricDebugEnabled() { + const char* enabled = getenv("NS_NS_FABRIC_DEBUG"); + return enabled != nullptr && enabled[0] != '\0' && strcmp(enabled, "0") != 0; } -- (void)applyUIKitHostHandles:(NSDictionary*)handles { - if (handles == nil) { +static void NativeScriptFabricDebugLog(NSString* format, ...) { + if (!NativeScriptFabricDebugEnabled()) { return; } - NSString* nativeViewHandle = handles[@"nativeViewHandle"]; - NSString* childrenViewHandle = handles[@"childrenViewHandle"]; - NSString* controllerHandle = handles[@"controllerHandle"]; + va_list args; + va_start(args, format); + NSString* message = [[NSString alloc] initWithFormat:format arguments:args]; + va_end(args); + NSLog(@"[NS_NS_FABRIC_DEBUG] %@", message); + [message release]; +} - if (controllerHandle.length > 0) { - self.controllerHandle = controllerHandle; - } - if (nativeViewHandle.length > 0) { - self.nativeViewHandle = nativeViewHandle; - } - if (childrenViewHandle.length > 0) { - self.childrenViewHandle = childrenViewHandle; +static NSString* NativeScriptFabricDebugChildEventSummary( + NSDictionary* event) { + if (event == nil) { + return @""; } - [self notifyHostReadyIfNeeded]; + + return [NSString stringWithFormat:@"idx=%@ ownerComponent=%@ ownerContainer=%@ ownerNative=%@ ownerChildren=%@ ownerController=%@ component=%@ container=%@ native=%@ children=%@ controller=%@", + event[@"index"] ?: @"", + event[@"ownerComponentViewHandle"] ?: @"", + event[@"ownerContainerViewHandle"] ?: @"", + event[@"ownerNativeViewHandle"] ?: @"", + event[@"ownerChildrenViewHandle"] ?: @"", + event[@"ownerControllerHandle"] ?: @"", + event[@"componentViewHandle"] ?: @"", + event[@"containerViewHandle"] ?: @"", + event[@"nativeViewHandle"] ?: @"", + event[@"childrenViewHandle"] ?: @"", + event[@"controllerHandle"] ?: @""]; } -- (void)mountUIKitHostIfNeeded { - if (_hostId.length == 0) { - return; +static NSString* NativeScriptTouchDebugViewSummary(UIView* view) { + if (view == nil) { + return @""; } - NSDictionary* handles = NativeScriptCreateUIKitHost(_hostId); - if (handles != nil) { - _hostMountRetryCount = 0; - [self applyUIKitHostHandles:handles]; - return; + NSMutableString* recognizers = [NSMutableString string]; + for (UIGestureRecognizer* recognizer in view.gestureRecognizers) { + if (recognizers.length > 0) { + [recognizers appendString:@","]; + } + [recognizers appendFormat:@"%@:%p", NSStringFromClass(recognizer.class), recognizer]; } - if (_hostMountRetryCount >= 8) { - return; - } + return [NSString stringWithFormat:@"%@:%p frame=%@ hidden=%d alpha=%.2f ui=%d window=%p gr=[%@]", + NSStringFromClass(view.class), + view, + NSStringFromCGRect(view.frame), + view.hidden, + view.alpha, + view.userInteractionEnabled, + view.window, + recognizers]; +} - _hostMountRetryCount += 1; - NSString* retryHostId = [_hostId copy]; - dispatch_async(dispatch_get_main_queue(), ^{ - if (retryHostId.length > 0 && [self->_hostId isEqualToString:retryHostId]) { - [self mountUIKitHostIfNeeded]; - } - [retryHostId release]; - }); +static NSString* NativeScriptTouchDebugAncestorSummary(UIView* view) { + NSMutableArray* parts = [NSMutableArray array]; + UIView* current = view; + NSUInteger depth = 0; + while (current != nil && depth < 12) { + [parts addObject:NativeScriptTouchDebugViewSummary(current)]; + current = current.superview; + depth += 1; + } + return [parts componentsJoinedByString:@" <- "]; } -- (void)runUIKitHostLifecycle:(NSString*)phase { - if (_hostId.length == 0 || phase.length == 0) { - return; +static BOOL NativeScriptViewHasSurfaceTouchHandler(UIView* view, id ignoredRecognizer) { +#if __has_include() + for (UIGestureRecognizer* recognizer in view.gestureRecognizers) { + if (recognizer != ignoredRecognizer && [recognizer isKindOfClass:RCTSurfaceTouchHandler.class]) { + return YES; + } } +#endif - [self mountUIKitHostIfNeeded]; - [self applyUIKitHostHandles:NativeScriptRunUIKitHostLifecycle(_hostId, phase)]; + return NO; } -- (void)setChildrenView:(UIView*)childrenView { - if (_childrenView == childrenView) { - return; +static BOOL NativeScriptViewHasOnlySurfaceTouchHandlers(UIView* view) { +#if __has_include() + if (view == nil || view.gestureRecognizers.count == 0) { + return NO; } - [self detachDetachedChildrenTouchHandler]; - [_detachedTouchSentinel removeFromSuperview]; - [_detachedTouchSentinel release]; - _detachedTouchSentinel = nil; - if (NativeScriptDetachedChildrenOwner(_childrenView) == self) { - NativeScriptSetDetachedChildrenOwner(_childrenView, nil); + for (UIGestureRecognizer* recognizer in view.gestureRecognizers) { + if (![recognizer isKindOfClass:RCTSurfaceTouchHandler.class]) { + return NO; + } } - [_childrenView release]; - _childrenView = [childrenView retain]; - NativeScriptSetDetachedChildrenOwner(_childrenView, self); - [self moveReactSubviewsToChildrenView]; - [self installDetachedChildrenTouchSentinelIfNeeded]; - [self attachDetachedChildrenTouchHandlerIfNeeded]; - [self notifyHostReadyIfNeeded]; + + return YES; +#else + return NO; +#endif } -- (void)setNativeView:(UIView*)nativeView { - if (_nativeView == nativeView) { - return; +static BOOL NativeScriptViewClassIsUIKitControllerBoundary(UIView* view) { + if (view == nil) { + return NO; } - [_nativeView removeFromSuperview]; - [_nativeView release]; - _nativeView = nil; + NSString* className = NSStringFromClass(view.class); + return [className containsString:@"UINavigationTransitionView"] || + [className containsString:@"UITransitionView"] || + [className containsString:@"UIViewControllerWrapperView"] || + [className containsString:@"UILayoutContainerView"]; +} - if (nativeView == nil) { - return; +static BOOL NativeScriptViewIsHostHitTestPlumbing(UIView* view) { + if (view == nil || [view isKindOfClass:UIControl.class]) { + return NO; } - _nativeView = [nativeView retain]; - [_nativeView removeFromSuperview]; - _nativeView.frame = self.bounds; - _nativeView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; - [super insertSubview:_nativeView atIndex:0]; - [self moveReactSubviewsToChildrenView]; - [self setNeedsLayout]; - [self notifyHostReadyIfNeeded]; -} + NSString* className = NSStringFromClass(view.class); + const BOOL isNativeScriptHost = [className isEqualToString:@"NativeScriptUIView"] || + [className isEqualToString:@"NativeScriptUIViewComponentView"]; + const BOOL isPlainSurfaceHost = + [className isEqualToString:@"UIView"] && + (view.gestureRecognizers.count == 0 || NativeScriptViewHasOnlySurfaceTouchHandlers(view)) && + view.subviews.count > 0; -- (void)setViewController:(UIViewController*)viewController { - if (_viewController == viewController) { - return; + if (!isNativeScriptHost && !isPlainSurfaceHost) { + return NO; } - [self detachViewController]; - [_viewController release]; + return view.gestureRecognizers.count == 0 || NativeScriptViewHasOnlySurfaceTouchHandlers(view); +} + +static BOOL NativeScriptViewHasUIKitControllerBoundaryAncestor(UIView* view, + UIView* stopView) { + UIView* current = view.superview; + NSUInteger depth = 0; + while (current != nil && current != stopView && depth < 16) { + if (NativeScriptViewClassIsUIKitControllerBoundary(current)) { + return YES; + } + + current = current.superview; + depth += 1; + } + + return NO; +} + +static BOOL NativeScriptViewHasSurfaceTouchHandlerInAncestorChain(UIView* view, + id ignoredRecognizer) { +#if __has_include() + UIView* current = view.superview; + NSUInteger depth = 0; + while (current != nil && depth < 32) { + if (current.hidden || current.alpha <= 0.01 || !current.userInteractionEnabled || + current.window == nil || current.window != view.window) { + return NO; + } + if (NativeScriptViewClassIsUIKitControllerBoundary(current)) { + return NO; + } + for (UIGestureRecognizer* recognizer in current.gestureRecognizers) { + if (recognizer != ignoredRecognizer && [recognizer isKindOfClass:RCTSurfaceTouchHandler.class]) { + if (NativeScriptViewHasUIKitControllerBoundaryAncestor(current, nil)) { + return NO; + } + + return YES; + } + } + current = current.superview; + depth += 1; + } +#endif + + return NO; +} + +static void NativeScriptUpdateSurfaceTouchHandlerOriginsInAncestorChain(UIView* view, + id ignoredRecognizer) { +#if __has_include() + if (view == nil || view.window == nil) { + return; + } + + UIView* current = view.superview; + NSUInteger depth = 0; + while (current != nil && depth < 32) { + if (current.hidden || current.alpha <= 0.01 || !current.userInteractionEnabled || + current.window == nil || current.window != view.window) { + return; + } + if (NativeScriptViewClassIsUIKitControllerBoundary(current)) { + return; + } + + CGPoint origin = [current convertPoint:CGPointZero toView:current.window]; + for (UIGestureRecognizer* recognizer in current.gestureRecognizers) { + if (recognizer == ignoredRecognizer || ![recognizer isKindOfClass:RCTSurfaceTouchHandler.class]) { + continue; + } + + if (NativeScriptViewHasUIKitControllerBoundaryAncestor(current, nil)) { + continue; + } + + ((RCTSurfaceTouchHandler*)recognizer).viewOriginOffset = origin; + } + + current = current.superview; + depth += 1; + } +#endif +} + +static void NativeScriptUpdateSurfaceTouchHandlerOrigins(UIView* view, id ignoredRecognizer) { +#if __has_include() + if (view == nil || view.window == nil) { + return; + } + + CGPoint origin = [view convertPoint:CGPointZero toView:view.window]; + for (UIGestureRecognizer* recognizer in view.gestureRecognizers) { + if (recognizer == ignoredRecognizer || ![recognizer isKindOfClass:RCTSurfaceTouchHandler.class]) { + continue; + } + + ((RCTSurfaceTouchHandler*)recognizer).viewOriginOffset = origin; + } +#endif +} + +static BOOL NativeScriptShouldForwardControllerAppearance(UIViewController* controller) { + return controller != nil && controller.view != nil && controller.view.window != nil; +} + +static BOOL NativeScriptHostedViewContainsControllerView(UIView* hostedView, + UIViewController* controller) { + return hostedView != nil && controller != nil && controller.view != nil && + NativeScriptViewIsDescendantOfView(controller.view, hostedView); +} + +static CGRect NativeScriptEffectiveTabBarHitBounds(UITabBar* tabBar) { + CGRect bounds = tabBar.bounds; + CGSize fittingSize = [tabBar sizeThatFits:CGSizeMake(bounds.size.width, bounds.size.height)]; + CGFloat maximumHeight = MAX(fittingSize.height + 32, 96); + + if (bounds.size.height > maximumHeight) { + bounds.origin.y = CGRectGetMaxY(bounds) - maximumHeight; + bounds.size.height = maximumHeight; + } + + return CGRectInset(bounds, -24, -16); +} + +static CGRect NativeScriptTabBarWindowHitFrame(UITabBar* tabBar, UIWindow* window) { + if (tabBar == nil) { + return CGRectNull; + } + + if (window != nil) { + return [tabBar convertRect:tabBar.bounds toView:window]; + } + + if (tabBar.superview != nil) { + return [tabBar.superview convertRect:tabBar.frame toView:nil]; + } + + return tabBar.frame; +} + +static CGRect NativeScriptTabBarWindowHitBounds(UITabBar* tabBar, UIWindow* window) { + CGRect frame = NativeScriptTabBarWindowHitFrame(tabBar, window); + if (CGRectIsNull(frame)) { + return frame; + } + + const CGFloat topEdge = window != nil ? window.safeAreaInsets.top + 20 : 64; + CGSize fittingSize = [tabBar sizeThatFits:CGSizeMake(frame.size.width, frame.size.height)]; + const CGFloat maximumHeight = MAX(fittingSize.height + 32, 96); + if (frame.size.height > maximumHeight) { + if (CGRectGetMinY(frame) <= topEdge) { + frame.size.height = maximumHeight; + } else { + frame.origin.y = CGRectGetMaxY(frame) - maximumHeight; + frame.size.height = maximumHeight; + } + } + + frame = CGRectInset(frame, -24, 0); + if (CGRectGetMinY(frame) <= topEdge) { + frame.origin.y -= 16; + frame.size.height += 16; + } else { + frame.origin.y -= 16; + frame.size.height += 32; + } + return frame; +} + +static BOOL NativeScriptPointInsideTabBarHitArea(UITabBar* tabBar, UIWindow* window, + CGPoint windowPoint) { + if (tabBar == nil || tabBar.hidden || tabBar.alpha <= 0.01 || + !tabBar.userInteractionEnabled) { + return NO; + } + + CGRect frameHitBounds = NativeScriptTabBarWindowHitBounds(tabBar, window); + if (!CGRectContainsPoint(frameHitBounds, windowPoint)) { + return NO; + } + + CGPoint localPoint = [tabBar convertPoint:windowPoint fromView:window]; + if (CGRectContainsPoint(NativeScriptEffectiveTabBarHitBounds(tabBar), localPoint)) { + return YES; + } + + return YES; +} + +static UITabBar* NativeScriptVisibleControllerTabBarAtPoint(UIViewController* controller, + UIWindow* window, + CGPoint windowPoint) { + if (controller == nil) { + return nil; + } + + UIViewController* presentedController = controller.presentedViewController; + if (presentedController != nil && !presentedController.isBeingDismissed) { + UITabBar* presentedTabBar = + NativeScriptVisibleControllerTabBarAtPoint(presentedController, window, windowPoint); + if (presentedTabBar != nil) { + return presentedTabBar; + } + } + + NSArray* childControllers = controller.childViewControllers; + for (UIViewController* childController in [childControllers reverseObjectEnumerator]) { + UITabBar* childTabBar = + NativeScriptVisibleControllerTabBarAtPoint(childController, window, windowPoint); + if (childTabBar != nil) { + return childTabBar; + } + } + + if ([controller isKindOfClass:UITabBarController.class]) { + UITabBarController* tabBarController = static_cast(controller); + UITabBar* tabBar = tabBarController.tabBar; + if (NativeScriptPointInsideTabBarHitArea(tabBar, window, windowPoint)) { + return tabBar; + } + } + + return nil; +} + +static UITabBar* NativeScriptVisibleWindowTabBarAtPoint(UIWindow* window, CGPoint windowPoint) { + if (window == nil) { + return nil; + } + + UITabBar* controllerTabBar = + NativeScriptVisibleControllerTabBarAtPoint(window.rootViewController, window, windowPoint); + if (controllerTabBar != nil) { + return controllerTabBar; + } + + return nil; +} + +static UITabBar* NativeScriptVisibleTabBarAtPoint(UIView* root, UIWindow* window, + CGPoint windowPoint) { + if (root == nil) { + return nil; + } + + if ([root isKindOfClass:UIWindow.class]) { + return NativeScriptVisibleWindowTabBarAtPoint(static_cast(root), windowPoint); + } + + if ([root isKindOfClass:UITabBar.class]) { + UITabBar* tabBar = static_cast(root); + if (NativeScriptPointInsideTabBarHitArea(tabBar, window, windowPoint)) { + return static_cast(root); + } + } + + for (UIView* subview in [root.subviews reverseObjectEnumerator]) { + UITabBar* tabBar = NativeScriptVisibleTabBarAtPoint(subview, window, windowPoint); + if (tabBar != nil) { + return tabBar; + } + } + + return nil; +} + +static UIView* NativeScriptHitTestTabBarAtPoint(UIView* root, UIWindow* window, + CGPoint windowPoint, UIEvent* event) { + UITabBar* tabBar = NativeScriptVisibleTabBarAtPoint(root, window, windowPoint); + if (tabBar == nil) { + return nil; + } + + CGPoint tabBarPoint = [tabBar convertPoint:windowPoint fromView:window]; + if (!CGRectContainsPoint(NativeScriptEffectiveTabBarHitBounds(tabBar), tabBarPoint) && + CGRectContainsPoint(NativeScriptTabBarWindowHitBounds(tabBar, window), windowPoint)) { + tabBarPoint = CGPointMake(windowPoint.x - tabBar.frame.origin.x, + windowPoint.y - tabBar.frame.origin.y); + } + UIView* tabBarHitView = [tabBar hitTest:tabBarPoint withEvent:event]; + if (tabBarHitView == tabBar && + CGRectContainsPoint(NativeScriptTabBarWindowHitBounds(tabBar, window), windowPoint)) { + CGPoint fallbackPoint = CGPointMake(windowPoint.x - tabBar.frame.origin.x, + windowPoint.y - tabBar.frame.origin.y); + UIView* fallbackHitView = [tabBar hitTest:fallbackPoint withEvent:event]; + if (fallbackHitView != nil && fallbackHitView != tabBar) { + return fallbackHitView; + } + } + return tabBarHitView ?: tabBar; +} +#if NATIVESCRIPT_RN_FABRIC_LAYOUT_METRICS_AVAILABLE +static const facebook::react::LayoutMetrics* +NativeScriptLayoutMetricsForFabricComponentView(id object); +#endif + +// The box Yoga gave `view`, when that is knowable. Only Fabric component views +// carry a Yoga box; for every other view (a host's children view, a +// UIViewController's view, UIKit's own transition containers) Yoga knows +// nothing, which is exactly the case the hosted fill exists to compensate for. +static BOOL NativeScriptFabricLayoutFrameForView(UIView* view, CGRect* outFrame) { +#if NATIVESCRIPT_RN_FABRIC_LAYOUT_METRICS_AVAILABLE + if (view == nil || !NativeScriptViewConformsToRCTComponentViewProtocol(view)) { + return NO; + } + const facebook::react::LayoutMetrics* metrics = + NativeScriptLayoutMetricsForFabricComponentView(view); + if (metrics == nullptr) { + return NO; + } + const CGRect frame = RCTCGRectFromRect(metrics->frame); + if (frame.size.width <= 0 && frame.size.height <= 0) { + return NO; + } + if (outFrame != nullptr) { + *outFrame = frame; + } + return YES; +#else + (void)view; + (void)outFrame; + return NO; +#endif +} + +static BOOL NativeScriptFabricLayoutSizeForView(UIView* view, CGSize* outSize) { + CGRect frame = CGRectZero; + if (!NativeScriptFabricLayoutFrameForView(view, &frame)) { + return NO; + } + if (outSize != nullptr) { + *outSize = frame.size; + } + return YES; +} + +static BOOL NativeScriptSubviewShouldFillParent(UIView* parent, UIView* child) { + if (parent == nil || child == nil) { + return NO; + } + + const CGRect parentBounds = parent.bounds; + const CGRect childFrame = child.frame; + if (parentBounds.size.width <= 0) { + return NO; + } + + if ([parent isKindOfClass:UIScrollView.class]) { + return fabs(childFrame.origin.x) < 1 && fabs(childFrame.origin.y) < 1 && + fabs(childFrame.size.width - parentBounds.size.width) < 2 && + childFrame.size.height > 0 && childFrame.size.height < parentBounds.size.height - 2; + } + + if (fabs(childFrame.origin.x) >= 1 || fabs(childFrame.origin.y) >= 1) { + return NO; + } + + CGSize parentLayoutSize = CGSizeZero; + CGSize childLayoutSize = CGSizeZero; + if (NativeScriptFabricLayoutSizeForView(parent, &parentLayoutSize) && + NativeScriptFabricLayoutSizeForView(child, &childLayoutSize)) { + // Yoga laid both of these out, so it owns the relationship between them. + // Re-stretch only the children Yoga itself stretched flush to the parent's + // box -- those are the ones that must follow the parent when the hosted + // fill grows it past its Yoga size. A child Yoga deliberately sized smaller + // (an explicit width/height, a self-sized header item) is the author's + // layout and must survive untouched. + // + // Both sides of this test read Yoga's boxes rather than the live frames, so + // the decision is stable no matter what has already been done to the frames + // by an earlier fill or by UIKit autoresizing. + return fabs(childLayoutSize.width - parentLayoutSize.width) < 2 && + fabs(childLayoutSize.height - parentLayoutSize.height) < 2; + } + + return childFrame.size.width <= 0 || + fabs(childFrame.size.width - parentBounds.size.width) < 2; +} + +static CGRect NativeScriptHostedSubviewFillFrame(UIView* parent) { + CGRect frame = parent.bounds; + if ([parent isKindOfClass:UIScrollView.class]) { + frame.origin = CGPointZero; + } + return frame; +} + +#if NATIVESCRIPT_RN_FABRIC_LAYOUT_METRICS_AVAILABLE +static const facebook::react::LayoutMetrics* +NativeScriptLayoutMetricsForFabricComponentView(id object) { + Class currentClass = object_getClass(object); + while (currentClass != Nil) { + Ivar layoutMetricsIvar = class_getInstanceVariable(currentClass, "_layoutMetrics"); + if (layoutMetricsIvar != nullptr) { + ptrdiff_t offset = ivar_getOffset(layoutMetricsIvar); + if (offset < 0) { + return nullptr; + } + auto* storage = reinterpret_cast(object) + offset; + return reinterpret_cast(storage); + } + currentClass = class_getSuperclass(currentClass); + } + return nullptr; +} + +#endif + +static void NativeScriptAppendRectSnapshot(NSMutableString* key, CGRect rect) { + [key appendFormat:@"%.3f,%.3f,%.3f,%.3f", + static_cast(rect.origin.x), + static_cast(rect.origin.y), + static_cast(rect.size.width), + static_cast(rect.size.height)]; +} + +static NSUInteger NativeScriptVisibleSubviewCountExcludingSentinel(UIView* view, UIView* sentinel) { + NSUInteger count = 0; + for (UIView* subview in view.subviews) { + if (subview != sentinel) { + count += 1; + } + } + return count; +} + +static void NativeScriptAppendSubviewTopology(NSMutableString* key, + UIView* root, + UIView* sentinel, + NSUInteger depth, + NSUInteger maxDepth) { + if (root == nil || depth > maxDepth) { + return; + } + + [key appendFormat:@"<%p:%lu", root, static_cast( + NativeScriptVisibleSubviewCountExcludingSentinel(root, sentinel))]; + for (UIView* subview in root.subviews) { + if (subview == sentinel) { + continue; + } + + [key appendFormat:@"|%p:%d:%.3f:%lu:", + subview, + subview.hidden ? 1 : 0, + static_cast(subview.alpha), + static_cast( + NativeScriptVisibleSubviewCountExcludingSentinel(subview, sentinel))]; + NativeScriptAppendRectSnapshot(key, subview.frame); + [key appendString:@":"]; + NativeScriptAppendRectSnapshot(key, subview.bounds); + + if (depth < maxDepth) { + NativeScriptAppendSubviewTopology(key, subview, sentinel, depth + 1, maxDepth); + } + } + [key appendString:@">"]; +} + +static NSString* NativeScriptDetachedChildrenLayoutSnapshotKey(UIView* childrenView, + UIView* sentinel) { + if (childrenView == nil) { + return @""; + } + + NSMutableString* key = [NSMutableString stringWithCapacity:160]; + [key appendFormat:@"%p|%p|", childrenView, childrenView.window]; + NativeScriptAppendRectSnapshot(key, childrenView.bounds); + [key appendFormat:@"|%lu", static_cast( + NativeScriptVisibleSubviewCountExcludingSentinel(childrenView, sentinel))]; + + for (UIView* subview in childrenView.subviews) { + if (subview == sentinel) { + continue; + } + + [key appendFormat:@"|%p:%lu:%d:%.3f:", + subview, + static_cast(subview.autoresizingMask), + subview.hidden ? 1 : 0, + static_cast(subview.alpha)]; + NativeScriptAppendRectSnapshot(key, subview.frame); + [key appendString:@":"]; + NativeScriptAppendRectSnapshot(key, subview.bounds); + } + + [key appendString:@"|tree:"]; + NativeScriptAppendSubviewTopology(key, childrenView, sentinel, 0, 3); + + return key; +} + +static NSString* NativeScriptDetachedChildrenDisplaySnapshotKey(UIView* childrenView, + UIView* sentinel) { + if (childrenView == nil) { + return @""; + } + + NSMutableString* key = [NSMutableString stringWithCapacity:220]; + [key appendFormat:@"%p|%p|%p|", + childrenView, + childrenView.superview, + childrenView.window]; + NativeScriptAppendRectSnapshot(key, childrenView.frame); + [key appendString:@"|"]; + NativeScriptAppendRectSnapshot(key, childrenView.bounds); + NativeScriptAppendSubviewTopology(key, childrenView, sentinel, 0, 2); + return key; +} + +static void NativeScriptInvalidateHostedSubviewDisplay(UIView* view, + UIView* sentinel, + NSUInteger depth) { + if (view == nil || view == sentinel || depth > 10) { + return; + } + + [view setNeedsDisplay]; + [view.layer setNeedsDisplay]; + + if (depth == 0) { + [view setNeedsLayout]; + } + + NSArray* subviews = [view.subviews copy]; + for (UIView* subview in subviews) { + NativeScriptInvalidateHostedSubviewDisplay(subview, sentinel, depth + 1); + } + [subviews release]; +} + +static void NativeScriptFlushHostedSubviewDisplay(UIView* view, + UIView* sentinel, + NSUInteger depth) { + if (view == nil || view == sentinel || depth > 10) { + return; + } + + [view.layer displayIfNeeded]; + + NSArray* subviews = [view.subviews copy]; + for (UIView* subview in subviews) { + NativeScriptFlushHostedSubviewDisplay(subview, sentinel, depth + 1); + } + [subviews release]; +} + +static BOOL NativeScriptLayoutHostedSubviewChain(UIView* root, + UIView* sentinel, + NSUInteger depth); + +static BOOL NativeScriptLayoutHostedSubviewChain(UIView* root, + UIView* sentinel, + NSUInteger depth) { + if (root == nil || root == sentinel || depth > 12) { + return NO; + } + + BOOL didMutate = NO; + const CGRect bounds = NativeScriptHostedSubviewFillFrame(root); + for (UIView* subview in root.subviews) { + if (subview == sentinel) { + continue; + } + if (!NativeScriptSubviewShouldFillParent(root, subview)) { + continue; + } + + // Deliberately NOT written into Fabric's cached layout metrics: the fill is + // a presentation-only override of a box Yoga under-sized, and Fabric's + // cache has to keep reporting Yoga's truth. Writing the filled frame back + // made the override permanent -- Fabric then believed the view was already + // laid out, so the real Yoga frame was never applied again and any hosted + // element with its own size was pinned to the host's bounds forever. + BOOL didMutateSubview = NO; + if (!CGRectEqualToRect(subview.frame, bounds)) { + subview.frame = bounds; + didMutateSubview = YES; + } + const UIViewAutoresizing flexibleSizeMask = + UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + if (subview.autoresizingMask != flexibleSizeMask) { + subview.autoresizingMask = flexibleSizeMask; + didMutateSubview = YES; + } + if (didMutateSubview) { + [subview setNeedsLayout]; + didMutate = YES; + } + didMutate = NativeScriptLayoutHostedSubviewChain(subview, sentinel, depth + 1) || didMutate; + } + + return didMutate; +} + +static UIView* NativeScriptHitTestVisibleDescendantOutsideBounds( + UIView* view, + CGPoint point, + UIEvent* event, + NSUInteger depth) { + if (view == nil || depth > 16 || view.hidden || view.alpha <= 0.01 || + !view.userInteractionEnabled || view.window == nil) { + return nil; + } + + NSArray* subviews = [view.subviews copy]; + for (UIView* subview in [subviews reverseObjectEnumerator]) { + if (subview.hidden || subview.alpha <= 0.01 || !subview.userInteractionEnabled || + subview.window == nil) { + continue; + } + + CGPoint subviewPoint = [subview convertPoint:point fromView:view]; + const BOOL subviewIsHostPlumbing = NativeScriptViewIsHostHitTestPlumbing(subview); + UIView* hitView = nil; + if (!subviewIsHostPlumbing) { + hitView = [subview hitTest:subviewPoint withEvent:event]; + if (hitView != nil && !NativeScriptViewIsHostHitTestPlumbing(hitView)) { + [subviews release]; + return hitView; + } + } + + hitView = + NativeScriptHitTestVisibleDescendantOutsideBounds(subview, subviewPoint, event, depth + 1); + if (hitView != nil && !NativeScriptViewIsHostHitTestPlumbing(hitView)) { + [subviews release]; + return hitView; + } + } + [subviews release]; + + return nil; +} + +static BOOL NativeScriptHostedOwnerViewPointInsideExcludingHost(UIView* ownerView, + UIView* hostView, + CGPoint point, + UIEvent* event, + NSUInteger depth) { + if (ownerView == nil || hostView == nil || depth > 16 || ownerView.hidden || + ownerView.alpha <= 0.01 || !ownerView.userInteractionEnabled || + ownerView.window == nil) { + return NO; + } + + NSArray* subviews = [ownerView.subviews copy]; + for (UIView* subview in [subviews reverseObjectEnumerator]) { + if (subview == hostView || NativeScriptViewIsDescendantOfView(hostView, subview) || + subview.hidden || subview.alpha <= 0.01 || !subview.userInteractionEnabled || + subview.window == nil) { + continue; + } + + CGPoint subviewPoint = [subview convertPoint:point fromView:ownerView]; + const BOOL subviewIsHostPlumbing = NativeScriptViewIsHostHitTestPlumbing(subview); + if ((!subviewIsHostPlumbing && [subview pointInside:subviewPoint withEvent:event]) || + NativeScriptHostedOwnerViewPointInsideExcludingHost( + subview, hostView, subviewPoint, event, depth + 1)) { + [subviews release]; + return YES; + } + } + [subviews release]; + + return NO; +} + +static UIView* NativeScriptHostedOwnerViewHitTestExcludingHost(UIView* ownerView, + UIView* hostView, + CGPoint point, + UIEvent* event, + NSUInteger depth) { + if (ownerView == nil || hostView == nil || depth > 16 || ownerView.hidden || + ownerView.alpha <= 0.01 || !ownerView.userInteractionEnabled || + ownerView.window == nil) { + return nil; + } + + NSArray* subviews = [ownerView.subviews copy]; + for (UIView* subview in [subviews reverseObjectEnumerator]) { + if (subview == hostView || NativeScriptViewIsDescendantOfView(hostView, subview) || + subview.hidden || subview.alpha <= 0.01 || !subview.userInteractionEnabled || + subview.window == nil) { + continue; + } + + CGPoint subviewPoint = [subview convertPoint:point fromView:ownerView]; + const BOOL subviewIsHostPlumbing = NativeScriptViewIsHostHitTestPlumbing(subview); + UIView* hitView = nil; + if (!subviewIsHostPlumbing) { + hitView = [subview hitTest:subviewPoint withEvent:event]; + if (hitView != nil && !NativeScriptViewIsHostHitTestPlumbing(hitView)) { + [subviews release]; + return hitView; + } + } + + hitView = + NativeScriptHostedOwnerViewHitTestExcludingHost(subview, hostView, subviewPoint, event, depth + 1); + if (hitView != nil && !NativeScriptViewIsHostHitTestPlumbing(hitView)) { + [subviews release]; + return hitView; + } + } + [subviews release]; + + return nil; +} + +@class NativeScriptUIView; +@class NativeScriptDetachedChildrenLayoutObserver; + +@interface NativeScriptDetachedChildrenLayoutObserver : NSObject +- (instancetype)initWithView:(UIView*)view owner:(NativeScriptUIView*)owner; +- (void)invalidate; +@end + +static const void* NativeScriptDetachedChildrenOwnerKey = + &NativeScriptDetachedChildrenOwnerKey; +static const void* NativeScriptDetachedChildrenLayoutObserverKey = + &NativeScriptDetachedChildrenLayoutObserverKey; +static const void* NativeScriptHostedViewOwnerKey = &NativeScriptHostedViewOwnerKey; + +static NativeScriptUIView* NativeScriptDetachedChildrenOwner(UIView* view) { + id owner = view == nil ? nil : objc_getAssociatedObject(view, NativeScriptDetachedChildrenOwnerKey); + if (owner == nil || ![owner isKindOfClass:NativeScriptUIView.class]) { + return nil; + } + + return static_cast(owner); +} + +static void NativeScriptSetDetachedChildrenOwner(UIView* view, NativeScriptUIView* owner) { + if (view == nil) { + return; + } + + id existingObserver = + objc_getAssociatedObject(view, NativeScriptDetachedChildrenLayoutObserverKey); + if (existingObserver != nil && [existingObserver respondsToSelector:@selector(invalidate)]) { + [existingObserver performSelector:@selector(invalidate)]; + objc_setAssociatedObject(view, NativeScriptDetachedChildrenLayoutObserverKey, nil, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } + + objc_setAssociatedObject( + view, NativeScriptDetachedChildrenOwnerKey, owner, OBJC_ASSOCIATION_ASSIGN); + + if (owner != nil) { + id observer = [[[NativeScriptDetachedChildrenLayoutObserver alloc] initWithView:view + owner:owner] + autorelease]; + objc_setAssociatedObject(view, NativeScriptDetachedChildrenLayoutObserverKey, observer, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } +} + +static NativeScriptUIView* NativeScriptHostedViewOwner(UIView* view) { + id owner = view == nil ? nil : objc_getAssociatedObject(view, NativeScriptHostedViewOwnerKey); + if (owner == nil || ![owner isKindOfClass:NativeScriptUIView.class]) { + return nil; + } + + return static_cast(owner); +} + +static void NativeScriptSetHostedViewOwner(UIView* view, NativeScriptUIView* owner) { + if (view == nil) { + return; + } + + objc_setAssociatedObject(view, NativeScriptHostedViewOwnerKey, owner, OBJC_ASSOCIATION_ASSIGN); +} + +@interface NativeScriptUIView () +- (void)attachDetachedChildrenTouchHandlerIfNeeded; +- (void)attachViewControllerIfPossible; +- (void)detachDetachedChildrenTouchHandler; +- (void)detachViewControllerIfOwnedByHost; +- (void)dismissViewControllerPresentationIfNeeded; +- (void)applyNativeViewLayoutMode; +- (void)deactivateNativeViewHostConstraints; +- (void)invalidateDetachedChildrenDisplay; +- (void)invalidateDetachedChildrenDisplayIfNeeded; +- (void)invalidateDetachedChildrenDisplaySnapshot; +- (void)invalidateDetachedChildrenLayoutSnapshot; +- (void)invalidateHostReadySnapshot; +- (void)installDetachedChildrenTouchSentinelIfNeeded; +- (BOOL)flushDetachedChildrenDisplay; +- (BOOL)layoutDetachedChildrenViewSubviewsAndReturnMutation; +- (void)notifyHostReadyIfNeeded; +- (void)refreshCollectedChildrenHostIfNeeded; +- (BOOL)refreshDetachedChildrenHost; +- (void)refreshDetachedChildrenSentinelAttachment; +- (void)refreshUIKitHostAfterNativeAttachment; +- (NSString*)fabricMountedChildLifecycleKeyForEvent:(NSDictionary*)event; +- (void)replayFabricMountedChildrenAsMountEventsIfNeeded; +- (void)scheduleUIKitHostMountedLifecycleIfNeeded; +- (void)scheduleUIKitHostPropsTransactionCommitIfNeeded; +- (void)setNeedsUIKitHostRefreshAfterNativeAttachment; +- (void)updateDetachedChildrenTouchHandlerOrigin; +- (NSDictionary*)uikitHostHandles; +- (void)runUIKitHostLifecycle:(NSString*)phase event:(NSDictionary*)event; +- (NSString*)fabricTransactionJsonWithModifiedChildren:(BOOL)hasModifiedChildren + modifiedProps:(BOOL)hasModifiedProps; +@end + +@implementation NativeScriptDetachedChildrenLayoutObserver { + UIView* _view; + NativeScriptUIView* _owner; + BOOL _observing; + BOOL _refreshing; +} + +- (instancetype)initWithView:(UIView*)view owner:(NativeScriptUIView*)owner { + if (self = [super init]) { + _view = [view retain]; + _owner = owner; + if (_view != nil) { + [_view addObserver:self forKeyPath:@"bounds" options:0 context:nil]; + [_view addObserver:self forKeyPath:@"frame" options:0 context:nil]; + _observing = YES; + } + } + return self; +} + +- (void)dealloc { + [self invalidate]; + [_view release]; + [super dealloc]; +} + +- (void)invalidate { + if (!_observing || _view == nil) { + _owner = nil; + return; + } + + @try { + [_view removeObserver:self forKeyPath:@"bounds"]; + [_view removeObserver:self forKeyPath:@"frame"]; + } @catch (__unused NSException* exception) { + } + _observing = NO; + _owner = nil; +} + +- (void)observeValueForKeyPath:(NSString*)keyPath + ofObject:(id)object + change:(NSDictionary*)change + context:(void*)context { + (void)change; + (void)context; + if (object != _view || + (![keyPath isEqualToString:@"bounds"] && ![keyPath isEqualToString:@"frame"])) { + [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]; + return; + } + + NativeScriptUIView* owner = _owner; + if (owner == nil || _refreshing) { + return; + } + + _refreshing = YES; + @try { + [owner refreshDetachedChildrenHost]; + } @finally { + _refreshing = NO; + } +} + +@end + +@interface NativeScriptDetachedChildrenTouchSentinel : UIView +@property(nonatomic, assign) NativeScriptUIView* owner; +@end + +@implementation NativeScriptDetachedChildrenTouchSentinel + +- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent*)event { + return NO; +} + +- (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent*)event { + return nil; +} + +- (void)didMoveToWindow { + [super didMoveToWindow]; + [self.owner refreshDetachedChildrenSentinelAttachment]; +} + +- (void)didMoveToSuperview { + [super didMoveToSuperview]; + [self.owner refreshDetachedChildrenSentinelAttachment]; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + [self.owner refreshDetachedChildrenSentinelAttachment]; +} + +@end + +@implementation NativeScriptUIView { + UIView* _nativeView; + UIView* _childrenView; + UIViewController* _viewController; + UIViewController* _attachedViewControllerParent; + id _detachedTouchHandler; + UIView* _detachedTouchHandlerView; + UIWindow* _detachedTouchHandlerWindow; + NativeScriptDetachedChildrenTouchSentinel* _detachedTouchSentinel; + NSString* _lastDetachedChildrenLayoutKey; + NSString* _lastDetachedChildrenDisplayKey; + NSString* _lastHostReadyKey; + NSString* _lastHostReadyShallowKey; + NSMutableArray* _fabricMountedChildComponentViews; + NSMutableArray* _collectedChildComponentViews; + NSMutableSet* _fabricMountedChildLifecycleKeys; + NSMutableSet* _fabricPendingUnmountTagsForCurrentTransaction; + NSArray* _nativeViewHostConstraints; + UIWindow* _lastUIKitHostAttachmentWindow; + BOOL _hasCreatedUIKitHost; + // CHANGE 1 (native one-shot `mounted` delivery): set exactly once, the + // first time this host's creation succeeds, guarding the one-shot + // dispatch_async in -scheduleUIKitHostMountedLifecycleIfNeeded so a host + // never gets more than one native-initiated "mounted" lifecycle delivery. + // Reset alongside _hasCreatedUIKitHost (see -setHostId: and -init). + BOOL _hasDeliveredMountedLifecycle; + BOOL _hasReplayedFabricTransactionAfterHostCreation; + BOOL _isNotifyingHostReady; + BOOL _isRefreshingUIKitHostAfterNativeAttachment; + BOOL _needsUIKitHostRefreshAfterNativeAttachment; + // SEAM D STAGE 0: unified Fabric transactionCommitted delivery token -- see + // the fabricTransactionDeliveryToken/advanceFabricTransactionDeliveryToken + // contract in the header. Replaces the historical, independently-bumped + // _uikitHostPropsTransactionCommitToken (this class) plus ComponentView's + // now-deleted _mountingTransactionToken and + // _fabricTransactionCommitFallbackToken. + NSUInteger _fabricTransactionDeliveryToken; +} + +- (instancetype)initWithFrame:(CGRect)frame { + NativeScriptInstallFabricReparentingGuard(); + self = [super initWithFrame:frame]; + if (self != nil) { + // Fabric bool props default to false. JS sends true for ordinary controller + // and native-view hosts, so keep native pre-prop values aligned with + // codegen and avoid attaching externally owned views before React delivers + // props. + _attachControllerToParent = NO; + _attachNativeView = NO; + // _hasCreatedUIKitHost is zero-initialized to NO like every other ivar + // here; _hasDeliveredMountedLifecycle mirrors it explicitly so the + // CHANGE 1 one-shot guard's reset is documented alongside the ivar it + // tracks (see -setHostId: for the other reset site). + _hasDeliveredMountedLifecycle = NO; + _needsUIKitHostRefreshAfterNativeAttachment = YES; + _fabricMountedChildComponentViews = [NSMutableArray new]; + _collectedChildComponentViews = [NSMutableArray new]; + _fabricMountedChildLifecycleKeys = [NSMutableSet new]; + } + return self; +} + +- (void)dealloc { + _fabricTransactionDeliveryToken += 1; + [self dismissViewControllerPresentationIfNeeded]; + [self detachViewControllerIfOwnedByHost]; + [self detachDetachedChildrenTouchHandler]; + _detachedTouchSentinel.owner = nil; + [_detachedTouchSentinel removeFromSuperview]; + [_detachedTouchSentinel release]; + [self deactivateNativeViewHostConstraints]; + if (NativeScriptHostedViewOwner(_nativeView) == self) { + NativeScriptSetHostedViewOwner(_nativeView, nil); + } + [_nativeView removeFromSuperview]; + [_nativeView release]; + if (NativeScriptDetachedChildrenOwner(_childrenView) == self) { + NativeScriptSetDetachedChildrenOwner(_childrenView, nil); + } + [_childrenView release]; + [_viewController release]; + [_detachedTouchHandler release]; + [_detachedTouchHandlerView release]; + [_nativeViewHandle release]; + [_childrenViewHandle release]; + [_controllerHandle release]; + [_hostId release]; + [_hostReadyId release]; + [_debugName release]; + [_uikitHostPropsJson release]; + [_onHostReady release]; + [_lastDetachedChildrenLayoutKey release]; + [_lastDetachedChildrenDisplayKey release]; + [_lastHostReadyKey release]; + [_lastHostReadyShallowKey release]; + [_fabricMountedChildComponentViews release]; + [_collectedChildComponentViews release]; + [_fabricMountedChildLifecycleKeys release]; + [_fabricPendingUnmountTagsForCurrentTransaction release]; + [super dealloc]; +} + +- (void)invalidateDetachedChildrenLayoutSnapshot { + [_lastDetachedChildrenLayoutKey release]; + _lastDetachedChildrenLayoutKey = nil; +} + +- (void)invalidateDetachedChildrenDisplaySnapshot { + [_lastDetachedChildrenDisplayKey release]; + _lastDetachedChildrenDisplayKey = nil; +} + +- (void)invalidateDetachedChildrenDisplay { + if (_childrenView == nil) { + return; + } + + NativeScriptInvalidateHostedSubviewDisplay(_childrenView, _detachedTouchSentinel, 0); + [_lastDetachedChildrenDisplayKey release]; + _lastDetachedChildrenDisplayKey = + [NativeScriptDetachedChildrenDisplaySnapshotKey(_childrenView, _detachedTouchSentinel) copy]; +} + +- (void)invalidateDetachedChildrenDisplayIfNeeded { + if (_childrenView == nil) { + return; + } + + NSString* displayKey = + NativeScriptDetachedChildrenDisplaySnapshotKey(_childrenView, _detachedTouchSentinel); + if ([_lastDetachedChildrenDisplayKey isEqualToString:displayKey]) { + return; + } + + NativeScriptInvalidateHostedSubviewDisplay(_childrenView, _detachedTouchSentinel, 0); + [_lastDetachedChildrenDisplayKey release]; + _lastDetachedChildrenDisplayKey = [displayKey copy]; +} + +- (BOOL)flushDetachedChildrenDisplay { + if (_childrenView == nil || _childrenView.window == nil) { + return NO; + } + + [self layoutDetachedChildrenViewSubviewsIfNeeded]; + NativeScriptInvalidateHostedSubviewDisplay(_childrenView, _detachedTouchSentinel, 0); + [_childrenView setNeedsLayout]; + [_childrenView layoutIfNeeded]; + NativeScriptFlushHostedSubviewDisplay(_childrenView, _detachedTouchSentinel, 0); + [_lastDetachedChildrenDisplayKey release]; + _lastDetachedChildrenDisplayKey = + [NativeScriptDetachedChildrenDisplaySnapshotKey(_childrenView, _detachedTouchSentinel) copy]; + return NativeScriptChildrenViewHasVisibleChild(_childrenView, _detachedTouchSentinel, self); +} + +- (void)invalidateHostReadySnapshot { + [_lastHostReadyKey release]; + _lastHostReadyKey = nil; + [_lastHostReadyShallowKey release]; + _lastHostReadyShallowKey = nil; +} + +- (void)setHostId:(NSString*)hostId { + if ((_hostId == hostId) || [_hostId isEqualToString:hostId]) { + return; + } + + NativeScriptFabricDebugLog(@"setHostId owner=%p debug=%@ previous=%@ next=%@ window=%p super=%@:%p propsJson=%d", + self, + _debugName ?: @"", + _hostId ?: @"", + hostId ?: @"", + self.window, + self.superview == nil ? @"nil" : NSStringFromClass(self.superview.class), + self.superview, + _uikitHostPropsJson.length > 0); + + NSString* previousHostId = [_hostId copy]; + if (previousHostId.length > 0) { + NativeScriptRunUIKitHostLifecycle(previousHostId, @"dispose", nil); + } + [previousHostId release]; + + [_hostId release]; + _hostId = [hostId copy]; + _fabricTransactionDeliveryToken += 1; + _hasCreatedUIKitHost = NO; + _hasDeliveredMountedLifecycle = NO; + _hasReplayedFabricTransactionAfterHostCreation = NO; + [_fabricMountedChildLifecycleKeys removeAllObjects]; + [self setNeedsUIKitHostRefreshAfterNativeAttachment]; + [self invalidateHostReadySnapshot]; + [self mountUIKitHostIfNeeded]; + [self notifyHostReadyIfNeeded]; +} + +- (void)setHostReadyId:(NSString*)hostReadyId { + if ((_hostReadyId == hostReadyId) || [_hostReadyId isEqualToString:hostReadyId]) { + return; + } + + [_hostReadyId release]; + _hostReadyId = [hostReadyId copy]; + [self invalidateHostReadySnapshot]; + [self notifyHostReadyIfNeeded]; +} + +- (void)setFabricLifecycleCallbacks:(BOOL)fabricLifecycleCallbacks { + if (_fabricLifecycleCallbacks == fabricLifecycleCallbacks) { + return; + } + + _fabricLifecycleCallbacks = fabricLifecycleCallbacks; + if (!_fabricLifecycleCallbacks) { + [_fabricMountedChildLifecycleKeys removeAllObjects]; + return; + } + + [self replayFabricMountedChildrenAsMountEventsIfNeeded]; + [self replayFabricTransactionAfterHostCreationIfNeeded]; +} + +- (void)setOnHostReady:(RCTDirectEventBlock)onHostReady { + if (_onHostReady == onHostReady) { + return; + } + + [_onHostReady release]; + _onHostReady = [onHostReady copy]; + [self notifyHostReadyIfNeeded]; +} + +- (void)setIgnoreHostReadyWindowAttachment:(BOOL)ignoreHostReadyWindowAttachment { + if (_ignoreHostReadyWindowAttachment == ignoreHostReadyWindowAttachment) { + return; + } + + _ignoreHostReadyWindowAttachment = ignoreHostReadyWindowAttachment; + [self invalidateHostReadySnapshot]; + [self notifyHostReadyIfNeeded]; +} + +- (void)clearNativeViewAttachmentIfOwnedByHost { + if (_nativeView == nil || NativeScriptHostedViewOwner(_nativeView) != self) { + return; + } + + [self setNeedsUIKitHostRefreshAfterNativeAttachment]; + [self deactivateNativeViewHostConstraints]; + NativeScriptSetHostedViewOwner(_nativeView, nil); + if (_nativeView.superview == self) { + [_nativeView removeFromSuperview]; + } + [_nativeView release]; + _nativeView = nil; + [self setNeedsLayout]; + [self notifyHostReadyIfNeeded]; + [self refreshUIKitHostAfterNativeAttachment]; +} + +- (void)setNativeViewHandle:(NSString*)nativeViewHandle { + const BOOL sameHandle = (_nativeViewHandle == nativeViewHandle) || + [_nativeViewHandle isEqualToString:nativeViewHandle]; + if (!sameHandle) { + [_nativeViewHandle release]; + _nativeViewHandle = [nativeViewHandle copy]; + } + if (!_attachNativeView) { + [self clearNativeViewAttachmentIfOwnedByHost]; + [self notifyHostReadyIfNeeded]; + return; + } + const BOOL mustClearDetachedControllerView = + _detachControllerView && _viewController != nil && _nativeView == _viewController.view; + if (sameHandle && (_nativeView != nil || _nativeViewHandle.length == 0) && + !mustClearDetachedControllerView) { + return; + } + + UIView* nativeView = NativeScriptUIViewFromHandle(_nativeViewHandle); + if (nativeView == nil && _nativeViewHandle.length == 0 && !_detachControllerView && + _viewController != nil) { + nativeView = _viewController.view; + } + [self setNativeView:nativeView]; +} + +- (void)setChildrenViewHandle:(NSString*)childrenViewHandle { + const BOOL sameHandle = (_childrenViewHandle == childrenViewHandle) || + [_childrenViewHandle isEqualToString:childrenViewHandle]; + if (sameHandle && (_childrenView != nil || _childrenViewHandle.length == 0)) { + return; + } + + if (!sameHandle) { + [_childrenViewHandle release]; + _childrenViewHandle = [childrenViewHandle copy]; + } + [self setChildrenView:NativeScriptUIViewFromHandle(_childrenViewHandle)]; +} + +- (void)setControllerHandle:(NSString*)controllerHandle { + const BOOL sameHandle = (_controllerHandle == controllerHandle) || + [_controllerHandle isEqualToString:controllerHandle]; + if (sameHandle && (_viewController != nil || _controllerHandle.length == 0)) { + return; + } + + if (!sameHandle) { + [_controllerHandle release]; + _controllerHandle = [controllerHandle copy]; + } + [self setViewController:NativeScriptUIViewControllerFromHandle(_controllerHandle)]; +} + +- (void)setAttachNativeView:(BOOL)attachNativeView { + if (_attachNativeView == attachNativeView) { + return; + } + + _attachNativeView = attachNativeView; + if (!_attachNativeView) { + [self clearNativeViewAttachmentIfOwnedByHost]; + [self notifyHostReadyIfNeeded]; + return; + } + + if (_nativeViewHandle.length > 0) { + [self setNativeViewHandle:_nativeViewHandle]; + } else if (!_detachControllerView && _viewController != nil) { + [self setNativeView:_viewController.view]; + } +} + +- (void)setDetachControllerView:(BOOL)detachControllerView { + if (_detachControllerView == detachControllerView) { + return; + } + + if (detachControllerView) { + [self detachViewControllerIfOwnedByHost]; + if (_viewController != nil && _nativeView == _viewController.view) { + [self setNativeView:nil]; + } + } + + _detachControllerView = detachControllerView; + + if (!_detachControllerView && _viewController != nil) { + if (_attachNativeView && _nativeViewHandle.length == 0) { + [self setNativeView:_viewController.view]; + } + [self attachViewControllerIfPossible]; + } +} + +- (void)setAttachControllerToParent:(BOOL)attachControllerToParent { + if (_attachControllerToParent == attachControllerToParent) { + return; + } + + if (!attachControllerToParent) { + [self detachViewControllerIfOwnedByHost]; + } + + _attachControllerToParent = attachControllerToParent; + + if (_attachControllerToParent) { + [self attachViewControllerIfPossible]; + } +} + +- (void)setCollectChildren:(BOOL)collectChildren { + if (_collectChildren == collectChildren) { + return; + } + + _collectChildren = collectChildren; + [self setNeedsUIKitHostRefreshAfterNativeAttachment]; + if (_collectChildren) { + if (_childrenView != nil) { + NSArray* subviews = [_childrenView.subviews copy]; + for (UIView* subview in subviews) { + if (subview == _detachedTouchSentinel) { + continue; + } + [subview removeFromSuperview]; + if (![_collectedChildComponentViews containsObject:subview]) { + [_collectedChildComponentViews addObject:subview]; + } + } + [subviews release]; + } + [self detachDetachedChildrenTouchHandler]; + [self invalidateDetachedChildrenLayoutSnapshot]; + [self invalidateDetachedChildrenDisplaySnapshot]; + [self invalidateHostReadySnapshot]; + [self refreshCollectedChildrenHostIfNeeded]; + return; + } + + if (_childrenView != nil && _collectedChildComponentViews.count > 0) { + NSArray* collectedChildren = [_collectedChildComponentViews copy]; + [_collectedChildComponentViews removeAllObjects]; + for (UIView* child in collectedChildren) { + [_childrenView addSubview:child]; + } + [collectedChildren release]; + } + [self layoutDetachedChildrenViewSubviewsIfNeeded]; + [self invalidateDetachedChildrenDisplay]; + [self installDetachedChildrenTouchSentinelIfNeeded]; + [self attachDetachedChildrenTouchHandlerIfNeeded]; + [self notifyHostReadyIfNeeded]; + [self refreshUIKitHostAfterNativeAttachment]; +} + +- (void)setDetachControllerFromParent:(BOOL)detachControllerFromParent { + if (_detachControllerFromParent == detachControllerFromParent) { + return; + } + + if (detachControllerFromParent) { + [self detachViewController]; + _attachedViewControllerParent = nil; + } + + _detachControllerFromParent = detachControllerFromParent; + + if (!_detachControllerFromParent) { + [self attachViewControllerIfPossible]; + } +} + +- (void)setDebugName:(NSString*)debugName { + if ((_debugName == debugName) || [_debugName isEqualToString:debugName]) { + return; + } + + [_debugName release]; + _debugName = [debugName copy]; +} + +- (void)setUikitHostPropsJson:(NSString*)uikitHostPropsJson { + if ((_uikitHostPropsJson == uikitHostPropsJson) || + [_uikitHostPropsJson isEqualToString:uikitHostPropsJson]) { + return; + } + + [_uikitHostPropsJson release]; + _uikitHostPropsJson = [uikitHostPropsJson copy]; +} + +- (NSUInteger)fabricTransactionDeliveryToken { + return _fabricTransactionDeliveryToken; +} + +- (NSUInteger)advanceFabricTransactionDeliveryToken { + return ++_fabricTransactionDeliveryToken; +} + +- (void)scheduleUIKitHostPropsTransactionCommitIfNeeded { + if (_hostId.length == 0 || _updateRevision <= 0) { + return; + } + + // SEAM D STAGE 0: never schedule this out-of-band props-revision commit + // while a Fabric mounting transaction is applying mutations to the owning + // ComponentView -- mountingTransactionDidMount is the legitimate initiator + // (RNS parity: RNSScreenStack.mm:1352-1370 delivers exactly-once, + // dispatch_async'd, ordered after layout -- NOT synchronously) and it + // already observes _hasModifiedPropsInCurrentTransaction (set alongside + // this same updateRevision bump; see updateProps/ + // applyNativeScriptUIKitHostProps) so it delivers this same props-modified + // commit itself once the transaction finishes. Scheduling here too just + // guarantees a duplicate delivery one runloop turn later -- this was + // producer #2 of the measured 4-6x per-pop transactionCommitted + // redelivery. Out of a transaction (the worklet-driven + // nativeScriptApplyUIKitHostPropsForFabricTag path, where didMount never + // fires) this remains the only delivery, so keep scheduling there. Mirrors + // the identical in-transaction skip already used by + // replayFabricTransactionAfterHostCreationIfNeeded above. + UIView* componentView = self.fabricComponentView; + if ([componentView isKindOfClass:NativeScriptUIViewComponentView.class] && + ((NativeScriptUIViewComponentView*)componentView).isApplyingMountingTransaction) { + return; + } + + const NSUInteger transactionToken = [self advanceFabricTransactionDeliveryToken]; + dispatch_async(dispatch_get_main_queue(), ^{ + if (self->_fabricTransactionDeliveryToken != transactionToken || + self->_hostId.length == 0) { + return; + } + + [self notifyFabricTransactionCommittedWithModifiedChildren:NO modifiedProps:YES]; + }); +} + +- (void)setUpdateRevision:(NSInteger)updateRevision { + if (_updateRevision == updateRevision) { + return; + } + + _updateRevision = updateRevision; + if (_updateRevision > 0) { + [self runUIKitHostLifecycle:@"update"]; + [self scheduleUIKitHostPropsTransactionCommitIfNeeded]; + } +} + +- (void)setMountedRevision:(NSInteger)mountedRevision { + if (_mountedRevision == mountedRevision) { + return; + } + + _mountedRevision = mountedRevision; + if (_mountedRevision > 0) { + [self runUIKitHostLifecycle:@"mounted"]; + } +} + +- (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]; +} + +- (NSDictionary*)hostReadyEventWithHasChildren:(BOOL)hasChildren + visibleDescendantCount:(NSUInteger)visibleDescendantCount + attachedWindow:(UIWindow*)attachedWindow { + NSString* readyId = _hostReadyId.length > 0 ? _hostReadyId : _hostId; + if (readyId.length == 0) { + return nil; + } + + NSMutableDictionary* event = [NSMutableDictionary dictionaryWithCapacity:9]; + event[@"hostReadyId"] = readyId; + event[@"hostId"] = _hostId ?: @""; + event[@"componentViewHandle"] = NativeScriptHandleFromNSObject(self.superview); + event[@"nativeViewHandle"] = + _nativeView != nil ? NativeScriptHandleFromNSObject(_nativeView) : (_nativeViewHandle ?: @""); + event[@"childrenViewHandle"] = NativeScriptHandleFromNSObject(_childrenView); + event[@"controllerHandle"] = NativeScriptHandleFromNSObject(_viewController); + event[@"hasChildren"] = @(hasChildren); + event[@"visibleDescendantCount"] = @(visibleDescendantCount); + event[@"windowAttached"] = @(attachedWindow != nil); + return event; +} + +- (UIWindow*)hostReadyAttachedWindow { + return _childrenView.window ?: _nativeView.window ?: _viewController.view.window ?: self.window; +} + +- (NSString*)nativeMountInfoJson { + NSMutableDictionary* info = + [NSMutableDictionary dictionaryWithCapacity:2]; + UIView* componentView = _fabricComponentView ?: self.superview; + if (componentView != nil) { + info[@"fabricComponentViewHandle"] = NativeScriptHandleFromNSObject(componentView); + } + info[@"fabricContainerViewHandle"] = NativeScriptHandleFromNSObject(self); + + if (![NSJSONSerialization isValidJSONObject:info]) { + return nil; + } + + NSError* error = nil; + NSData* data = [NSJSONSerialization dataWithJSONObject:info options:0 error:&error]; + if (data == nil) { + return nil; + } + + return [[[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] autorelease]; +} + +- (NSString*)hostReadyShallowKeyWithHasChildren:(BOOL)hasChildren + attachedWindow:(UIWindow*)attachedWindow { + NSString* readyId = _hostReadyId.length > 0 ? _hostReadyId : _hostId; + if (readyId.length == 0) { + return nil; + } + + void* windowKey = _ignoreHostReadyWindowAttachment ? NULL : (void*)attachedWindow; + NSMutableString* key = [NSMutableString stringWithCapacity:220]; + [key appendFormat:@"%@|%@|%@|%@|%@|%@|%p|", + readyId ?: @"", + _hostId ?: @"", + _nativeView != nil ? NativeScriptHandleFromNSObject(_nativeView) + : (_nativeViewHandle ?: @""), + NativeScriptHandleFromNSObject(_childrenView), + NativeScriptHandleFromNSObject(_viewController), + hasChildren ? @"1" : @"0", + windowKey]; + NativeScriptAppendSubviewTopology(key, _childrenView, _detachedTouchSentinel, 0, 2); + return key; +} + +- (void)notifyHostReadyIfNeeded { + static BOOL isDeliveringHostReady; + if (isDeliveringHostReady) { + return; + } + + if (_isNotifyingHostReady) { + return; + } + + const BOOL hasChildren = + NativeScriptChildrenViewHasVisibleChild(_childrenView, _detachedTouchSentinel, self); + if (!hasChildren) { + return; + } + + UIWindow* attachedWindow = [self hostReadyAttachedWindow]; + if (attachedWindow == nil && !_emitOffWindowHostReady) { + return; + } + + NSString* shallowKey = [self hostReadyShallowKeyWithHasChildren:hasChildren + attachedWindow:attachedWindow]; + if (shallowKey == nil) { + return; + } + if (_lastHostReadyKey != nil && [_lastHostReadyShallowKey isEqualToString:shallowKey]) { + return; + } + + const NSUInteger visibleDescendantCount = + NativeScriptChildrenViewVisibleDescendantCount(_childrenView, _detachedTouchSentinel, self); + NSDictionary* event = + [self hostReadyEventWithHasChildren:hasChildren + visibleDescendantCount:visibleDescendantCount + attachedWindow:attachedWindow]; + if (event == nil) { + return; + } + + NSString* key = [NSString + stringWithFormat:@"%@|%@|%@|%@|%@|%@|%@|%@", + event[@"hostReadyId"] ?: @"", + event[@"hostId"] ?: @"", + event[@"nativeViewHandle"] ?: @"", + event[@"childrenViewHandle"] ?: @"", + event[@"controllerHandle"] ?: @"", + [event[@"hasChildren"] boolValue] ? @"1" : @"0", + [event[@"windowAttached"] boolValue] ? @"1" : @"0", + event[@"visibleDescendantCount"] ?: @(0)]; + if ([_lastHostReadyKey isEqualToString:key]) { + [_lastHostReadyShallowKey release]; + _lastHostReadyShallowKey = [shallowKey copy]; + return; + } + + [_lastHostReadyKey release]; + _lastHostReadyKey = [key copy]; + [_lastHostReadyShallowKey release]; + _lastHostReadyShallowKey = [shallowKey copy]; + + _isNotifyingHostReady = YES; + isDeliveringHostReady = YES; + @try { + if (_hostId.length > 0 && [NSJSONSerialization isValidJSONObject:event]) { + NSError* error = nil; + NSData* eventData = [NSJSONSerialization dataWithJSONObject:event options:0 error:&error]; + if (eventData != nil) { + NSString* eventJson = [[NSString alloc] initWithData:eventData + encoding:NSUTF8StringEncoding]; + [self runUIKitHostLifecycle:@"hostReady" transactionJson:eventJson]; + [eventJson release]; + } + } + + if (_onHostReady != nil) { + _onHostReady(event); + } + if ([_hostReadyDelegate respondsToSelector:@selector(nativeScriptUIView:didHostReady:)]) { + [_hostReadyDelegate nativeScriptUIView:self didHostReady:event]; + } + } @finally { + isDeliveringHostReady = NO; + _isNotifyingHostReady = NO; + } +} + +- (void)refreshUIKitHostAfterNativeAttachment { + UIWindow* currentWindow = self.window; + if (currentWindow == nil) { + return; + } + + if (_hostId.length == 0 || + _disableUIKitHostWindowAttachRefresh || + _isRefreshingUIKitHostAfterNativeAttachment) { + return; + } + + if (!_needsUIKitHostRefreshAfterNativeAttachment && + _lastUIKitHostAttachmentWindow == currentWindow) { + return; + } + + _lastUIKitHostAttachmentWindow = currentWindow; + _needsUIKitHostRefreshAfterNativeAttachment = NO; + _isRefreshingUIKitHostAfterNativeAttachment = YES; + @try { + [self runUIKitHostLifecycle:@"refresh" + transactionJson:[self fabricTransactionJsonWithModifiedChildren:NO + modifiedProps:NO]]; + } @finally { + _isRefreshingUIKitHostAfterNativeAttachment = NO; + } +} + +- (void)setNeedsUIKitHostRefreshAfterNativeAttachment { + _needsUIKitHostRefreshAfterNativeAttachment = YES; +} + +- (void)applyUIKitHostHandles:(NSDictionary*)handles { + if (handles == nil) { + return; + } + + // CHANGE 1 (native one-shot `mounted` delivery): snapshot BEFORE flipping + // _hasCreatedUIKitHost below, so this is YES only the very first time this + // host's handles are ever applied. + const BOOL firstCreation = !_hasCreatedUIKitHost; + _hasCreatedUIKitHost = YES; + NSString* nativeViewHandle = handles[@"nativeViewHandle"]; + NSString* childrenViewHandle = handles[@"childrenViewHandle"]; + NSString* controllerHandle = handles[@"controllerHandle"]; + UIViewController* nextController = + controllerHandle.length > 0 ? NativeScriptUIViewControllerFromHandle(controllerHandle) : nil; + UIView* nextNativeView = + nativeViewHandle.length > 0 ? NativeScriptUIViewFromHandle(nativeViewHandle) : nil; + const BOOL nativeViewIsDetachedControllerView = + _detachControllerView && nextController != nil && nextNativeView == nextController.view; + + if (nativeViewIsDetachedControllerView) { + if (controllerHandle.length > 0) { + self.controllerHandle = controllerHandle; + } + if (childrenViewHandle.length > 0) { + self.childrenViewHandle = childrenViewHandle; + } + if (_attachNativeView && nativeViewHandle.length > 0) { + self.nativeViewHandle = nativeViewHandle; + } else if (!_attachNativeView && nativeViewHandle.length > 0) { + [_nativeViewHandle release]; + _nativeViewHandle = [nativeViewHandle copy]; + [self notifyHostReadyIfNeeded]; + } + } else { + if (_attachNativeView && nativeViewHandle.length > 0) { + self.nativeViewHandle = nativeViewHandle; + } else if (!_attachNativeView && nativeViewHandle.length > 0) { + [_nativeViewHandle release]; + _nativeViewHandle = [nativeViewHandle copy]; + [self notifyHostReadyIfNeeded]; + } + if (childrenViewHandle.length > 0) { + self.childrenViewHandle = childrenViewHandle; + } + if (controllerHandle.length > 0) { + self.controllerHandle = controllerHandle; + } + } + [self notifyHostReadyIfNeeded]; + + if (firstCreation) { + [self scheduleUIKitHostMountedLifecycleIfNeeded]; + } +} + +// CHANGE 1 (native one-shot `mounted` delivery): today the ONLY delivery of +// the "mounted" lifecycle phase is JS-render-initiated -- a successful +// prepareUIKitHostOnUI resolution bumps nativeHostRevision, which flows into +// the mountedRevision native prop, whose setter (-setMountedRevision: above) +// calls -runUIKitHostLifecycle:@"mounted". That crossing runs on the render +// path and, prior to this change, had to keep polling every props revision +// forever (see index.ts prepareUIKitHostOnUI) purely so this one lifecycle +// call would eventually fire once host creation actually succeeded -- +// contending for runtimeMutex_ against the Fabric mount that starts a pop. +// Deliver "mounted" from here instead, the moment host creation succeeds, +// with no dependency on any JS render round-trip. JS's own handler stays +// unconditionally safe with BOTH sources still live (this one-shot AND the +// existing render-driven fallback, until Change 2 gates the fallback off): +// phase "mounted" is idempotent there (`if (!host.hasMounted) { ... }`, see +// index.ts), and -setMountedRevision: above already dedupes identical +// revisions, so a later, redundant delivery from the still-live JS polling +// fallback is a harmless no-op. +- (void)scheduleUIKitHostMountedLifecycleIfNeeded { + if (_hostId.length == 0 || _hasDeliveredMountedLifecycle) { + return; + } + + // Snapshot _hostId so the block below can detect this host being reused + // for a different screen/hostId (-setHostId:) before the dispatch runs -- + // mirrors -scheduleUIKitHostPropsTransactionCommitIfNeeded's token guard, + // just keyed on hostId-plus-a-bool rather than a monotonic counter. Do NOT + // reuse _fabricTransactionDeliveryToken here: it is bumped on every + // transactionCommitted delivery (see + // -notifyFabricTransactionCommittedWithModifiedChildren:...) and would + // cancel this pending "mounted" delivery before it ever ran. + NSString* scheduledHostId = [_hostId copy]; + dispatch_async(dispatch_get_main_queue(), ^{ + if (![self->_hostId isEqualToString:scheduledHostId] || + !self->_hasCreatedUIKitHost || self->_hasDeliveredMountedLifecycle) { + [scheduledHostId release]; + return; + } + + self->_hasDeliveredMountedLifecycle = YES; + [self runUIKitHostLifecycle:@"mounted"]; + [scheduledHostId release]; + }); +} + +- (void)replayFabricTransactionAfterHostCreationIfNeeded { + if (!_fabricLifecycleCallbacks || !_hasCreatedUIKitHost || + _hasReplayedFabricTransactionAfterHostCreation) { + return; + } + + UIView* componentView = self.fabricComponentView; + if ([componentView isKindOfClass:NativeScriptUIViewComponentView.class] && + ((NativeScriptUIViewComponentView*)componentView).isApplyingMountingTransaction) { + // The host was created lazily while this mounting transaction is still + // applying mutations, so the mounted-children snapshot may be partial + // (host creation triggers on the FIRST child mount). Skip the replay: + // mountingTransactionDidMount delivers the complete transaction once. + NativeScriptFabricDebugLog(@"replayTransactionAfterHost skip-mid-transaction owner=%p debug=%@ hostId=%@", + self, + _debugName ?: @"", + _hostId ?: @""); + return; + } + + NSArray*>* mountedChildren = [self fabricMountedChildrenSnapshot]; + NativeScriptFabricDebugLog(@"replayTransactionAfterHost owner=%p debug=%@ hostId=%@ childCount=%lu replayed=%d", + self, + _debugName ?: @"", + _hostId ?: @"", + static_cast(mountedChildren.count), + _hasReplayedFabricTransactionAfterHostCreation); + if (mountedChildren.count == 0) { + return; + } + + _hasReplayedFabricTransactionAfterHostCreation = YES; + [self notifyFabricTransactionCommittedWithModifiedChildren:YES modifiedProps:YES]; +} + +- (NSString*)fabricMountedChildLifecycleKeyForEvent:(NSDictionary*)event { + NSString* componentViewHandle = + [event[@"componentViewHandle"] isKindOfClass:NSString.class] ? event[@"componentViewHandle"] : @""; + NSString* containerViewHandle = + [event[@"containerViewHandle"] isKindOfClass:NSString.class] ? event[@"containerViewHandle"] : @""; + NSString* nativeViewHandle = + [event[@"nativeViewHandle"] isKindOfClass:NSString.class] ? event[@"nativeViewHandle"] : @""; + NSString* childrenViewHandle = + [event[@"childrenViewHandle"] isKindOfClass:NSString.class] ? event[@"childrenViewHandle"] : @""; + NSString* controllerHandle = + [event[@"controllerHandle"] isKindOfClass:NSString.class] ? event[@"controllerHandle"] : @""; + NSNumber* index = [event[@"index"] isKindOfClass:NSNumber.class] ? event[@"index"] : @(NSNotFound); + + if (componentViewHandle.length == 0 && containerViewHandle.length == 0 && + nativeViewHandle.length == 0 && childrenViewHandle.length == 0 && + controllerHandle.length == 0) { + return nil; + } + + return [NSString stringWithFormat:@"%@|%@|%@|%@|%@|%@", + componentViewHandle, + containerViewHandle, + nativeViewHandle, + childrenViewHandle, + controllerHandle, + index]; +} + +- (void)replayFabricMountedChildrenAsMountEventsIfNeeded { + if (_hostId.length == 0 || !_fabricLifecycleCallbacks || !_hasCreatedUIKitHost) { + NativeScriptFabricDebugLog(@"replayChildren skip owner=%p debug=%@ hostId=%@ callbacks=%d created=%d", + self, + _debugName ?: @"", + _hostId ?: @"", + _fabricLifecycleCallbacks, + _hasCreatedUIKitHost); + return; + } + + NSArray*>* mountedChildren = [self fabricMountedChildrenSnapshot]; + NativeScriptFabricDebugLog(@"replayChildren begin owner=%p debug=%@ hostId=%@ count=%lu deliveredKeys=%lu", + self, + _debugName ?: @"", + _hostId ?: @"", + static_cast(mountedChildren.count), + static_cast(_fabricMountedChildLifecycleKeys.count)); + for (NSDictionary* event in mountedChildren) { + NSString* childKey = [self fabricMountedChildLifecycleKeyForEvent:event]; + if (childKey.length == 0 || [_fabricMountedChildLifecycleKeys containsObject:childKey]) { + NativeScriptFabricDebugLog(@"replayChildren ignore owner=%p debug=%@ hostId=%@ key=%@ event={%@}", + self, + _debugName ?: @"", + _hostId ?: @"", + childKey ?: @"", + NativeScriptFabricDebugChildEventSummary(event)); + continue; + } + + [_fabricMountedChildLifecycleKeys addObject:childKey]; + NativeScriptFabricDebugLog(@"replayChildren mount owner=%p debug=%@ hostId=%@ key=%@ event={%@}", + self, + _debugName ?: @"", + _hostId ?: @"", + childKey, + NativeScriptFabricDebugChildEventSummary(event)); + [self runUIKitHostLifecycle:@"mountChild" event:event]; + } +} + +- (void)mountUIKitHostIfNeeded { + if (_hostId.length == 0 || _hasCreatedUIKitHost) { + NativeScriptFabricDebugLog(@"mountUIKitHost skip owner=%p debug=%@ hostId=%@ hasCreated=%d window=%p", + self, + _debugName ?: @"", + _hostId ?: @"", + _hasCreatedUIKitHost, + self.window); + return; + } + + NSString* nativeMountInfoJson = [self nativeMountInfoJson]; + NativeScriptFabricDebugLog(@"mountUIKitHost create owner=%p debug=%@ hostId=%@ window=%p super=%@:%p propsJson=%d nativeInfo=%@", + self, + _debugName ?: @"", + _hostId ?: @"", + self.window, + self.superview == nil ? @"nil" : NSStringFromClass(self.superview.class), + self.superview, + _uikitHostPropsJson.length > 0, + nativeMountInfoJson ?: @""); + NSDictionary* handles = NativeScriptCreateUIKitHostWithInfo( + _hostId, _uikitHostPropsJson, nativeMountInfoJson); + NativeScriptFabricDebugLog(@"mountUIKitHost result owner=%p debug=%@ hostId=%@ handles=%@", + self, + _debugName ?: @"", + _hostId ?: @"", + handles ?: @{}); + if (handles != nil) { + [self applyUIKitHostHandles:handles]; + [self replayFabricMountedChildrenAsMountEventsIfNeeded]; + [self replayFabricTransactionAfterHostCreationIfNeeded]; + return; + } +} + +- (void)runUIKitHostLifecycle:(NSString*)phase transactionJson:(NSString*)transactionJson { + if (_hostId.length == 0 || phase.length == 0) { + return; + } + + [self mountUIKitHostIfNeeded]; + NSDictionary* handles = + transactionJson.length > 0 + ? NativeScriptRunUIKitHostLifecycleWithInfo(_hostId, phase, _uikitHostPropsJson, + transactionJson, [self nativeMountInfoJson]) + : NativeScriptRunUIKitHostLifecycleWithInfo(_hostId, phase, _uikitHostPropsJson, nil, + [self nativeMountInfoJson]); + [self applyUIKitHostHandles:handles]; +} + +- (void)runUIKitHostLifecycle:(NSString*)phase { + [self runUIKitHostLifecycle:phase transactionJson:nil]; +} + +- (void)runUIKitHostLifecycle:(NSString*)phase event:(NSDictionary*)event { + if (event == nil || ![NSJSONSerialization isValidJSONObject:event]) { + [self runUIKitHostLifecycle:phase transactionJson:nil]; + return; + } + + NSError* error = nil; + NSData* eventData = [NSJSONSerialization dataWithJSONObject:event options:0 error:&error]; + if (eventData == nil) { + [self runUIKitHostLifecycle:phase transactionJson:nil]; + return; + } + + NSString* eventJson = [[NSString alloc] initWithData:eventData + encoding:NSUTF8StringEncoding]; + [self runUIKitHostLifecycle:phase transactionJson:eventJson]; + [eventJson release]; +} + +- (NSDictionary*)uikitHostHandles { + return @{ + @"componentViewHandle" : NativeScriptHandleFromNSObject(self.superview), + @"containerViewHandle" : NativeScriptHandleFromNSObject(self), + @"nativeViewHandle" : + _nativeView != nil ? NativeScriptHandleFromNSObject(_nativeView) : (_nativeViewHandle ?: @""), + @"childrenViewHandle" : NativeScriptHandleFromNSObject(_childrenView), + @"controllerHandle" : NativeScriptHandleFromNSObject(_viewController), + }; +} + +- (NSDictionary*)fabricChildEventForComponentView:(UIView*)componentView + childContainerView:(UIView*)childContainerView + index:(NSInteger)index { + NSDictionary* ownerHandles = [self uikitHostHandles]; + NSDictionary* childHandles = @{}; + if ([childContainerView isKindOfClass:NativeScriptUIView.class]) { + childHandles = [static_cast(childContainerView) uikitHostHandles]; + } + + return @{ + @"index" : @(index), + @"ownerComponentViewHandle" : NativeScriptHandleFromNSObject(self.superview), + @"ownerContainerViewHandle" : NativeScriptHandleFromNSObject(self), + @"ownerNativeViewHandle" : ownerHandles[@"nativeViewHandle"] ?: @"", + @"ownerChildrenViewHandle" : ownerHandles[@"childrenViewHandle"] ?: @"", + @"ownerControllerHandle" : ownerHandles[@"controllerHandle"] ?: @"", + @"componentViewHandle" : NativeScriptHandleFromNSObject(componentView), + @"containerViewHandle" : NativeScriptHandleFromNSObject(childContainerView), + @"nativeViewHandle" : childHandles[@"nativeViewHandle"] ?: @"", + @"childrenViewHandle" : childHandles[@"childrenViewHandle"] ?: @"", + @"controllerHandle" : childHandles[@"controllerHandle"] ?: @"", + }; +} + +- (void)notifyFabricMountingTransactionWillMount { + [self runUIKitHostLifecycle:@"mountingTransactionWillMount"]; +} + +- (void)notifyFabricChildMounted:(UIView*)componentView + childContainerView:(UIView*)childContainerView + index:(NSInteger)index { + NSDictionary* event = [self fabricChildEventForComponentView:componentView + childContainerView:childContainerView + index:index]; + NSString* childKey = [self fabricMountedChildLifecycleKeyForEvent:event]; + NativeScriptFabricDebugLog(@"notifyChildMounted owner=%p debug=%@ hostId=%@ created=%d key=%@ event={%@}", + self, + _debugName ?: @"", + _hostId ?: @"", + _hasCreatedUIKitHost, + childKey ?: @"", + NativeScriptFabricDebugChildEventSummary(event)); + if (_hostId.length > 0 && childKey.length > 0) { + [_fabricMountedChildLifecycleKeys addObject:childKey]; + } + [self runUIKitHostLifecycle:@"mountChild" event:event]; + if (!_hasCreatedUIKitHost) { + [_fabricMountedChildLifecycleKeys removeObject:childKey]; + } +} + +- (void)notifyFabricChildUnmounted:(UIView*)componentView + childContainerView:(UIView*)childContainerView + index:(NSInteger)index { + NSDictionary* event = [self fabricChildEventForComponentView:componentView + childContainerView:childContainerView + index:index]; + NSString* childKey = [self fabricMountedChildLifecycleKeyForEvent:event]; + [self runUIKitHostLifecycle:@"unmountChild" event:event]; + [_fabricMountedChildLifecycleKeys removeObject:childKey]; +} + +- (NSArray*>*)fabricMountedChildrenSnapshot { + NSMutableArray* mountedChildren = [NSMutableArray array]; + void (^appendChildren)(NSArray*) = ^(NSArray* children) { + for (UIView* child in children) { + if (child == nil || [mountedChildren containsObject:child]) { + continue; + } + [mountedChildren addObject:child]; + } + }; + + appendChildren(_fabricMountedChildComponentViews); + if (_collectChildren) { + appendChildren(_collectedChildComponentViews); + appendChildren(NativeScriptRelocatedFabricChildrenForSuperview(_childrenView ?: self)); + if (_childrenView != nil) { + appendChildren(_childrenView.subviews); + } + appendChildren(self.subviews); + } else if (_childrenView != nil) { + appendChildren(_childrenView.subviews); + appendChildren(NativeScriptRelocatedFabricChildrenForSuperview(_childrenView)); + if (_childrenView != self && NativeScriptViewIsDescendantOfView(self, _childrenView)) { + appendChildren(self.subviews); + } + } else { + appendChildren(self.subviews); + appendChildren(NativeScriptRelocatedFabricChildrenForSuperview(self)); + } + + NSMutableArray*>* snapshot = + [NSMutableArray arrayWithCapacity:mountedChildren.count]; + NSInteger childIndex = 0; + + for (UIView* child in mountedChildren) { + if (child == nil || child == self || child == _nativeView || child == _childrenView || + child == _detachedTouchSentinel) { + continue; + } + + [snapshot addObject:[self + fabricChildEventForComponentView:child + childContainerView: + NativeScriptCurrentContainerViewForComponentView(child) + index:childIndex]]; + childIndex += 1; + } + + return snapshot; +} + +- (void)setChildrenView:(UIView*)childrenView { + if (_childrenView == childrenView) { + return; + } + + [self setNeedsUIKitHostRefreshAfterNativeAttachment]; + [self detachDetachedChildrenTouchHandler]; + _detachedTouchSentinel.owner = nil; + [_detachedTouchSentinel removeFromSuperview]; + [_detachedTouchSentinel release]; + _detachedTouchSentinel = nil; + [self invalidateDetachedChildrenLayoutSnapshot]; + [self invalidateDetachedChildrenDisplaySnapshot]; + [self invalidateHostReadySnapshot]; + if (NativeScriptDetachedChildrenOwner(_childrenView) == self) { + NativeScriptSetDetachedChildrenOwner(_childrenView, nil); + } + [_childrenView release]; + _childrenView = [childrenView retain]; + NativeScriptSetDetachedChildrenOwner(_childrenView, self); + [self moveReactSubviewsToChildrenView]; + if (_mountChildrenDirectlyToChildrenView) { + if (_layoutDirectChildrenToChildrenViewBounds) { + NativeScriptLayoutHostedSubviewChain(_childrenView, _detachedTouchSentinel, 0); + } + [self invalidateHostReadySnapshot]; + [self notifyHostReadyIfNeeded]; + return; + } + [self invalidateDetachedChildrenDisplay]; + [self installDetachedChildrenTouchSentinelIfNeeded]; + [self attachDetachedChildrenTouchHandlerIfNeeded]; + [self notifyHostReadyIfNeeded]; +} + +- (void)deactivateNativeViewHostConstraints { + if (_nativeViewHostConstraints == nil) { + return; + } + + [NSLayoutConstraint deactivateConstraints:_nativeViewHostConstraints]; + [_nativeViewHostConstraints release]; + _nativeViewHostConstraints = nil; +} + +- (void)applyNativeViewLayoutMode { + if (_nativeView == nil) { + [self deactivateNativeViewHostConstraints]; + return; + } + + const BOOL nativeViewIsOwnedByHost = _nativeView.superview == self; + if (!nativeViewIsOwnedByHost) { + [self deactivateNativeViewHostConstraints]; + return; + } + + if (!_pinNativeViewToHost) { + [self deactivateNativeViewHostConstraints]; + _nativeView.translatesAutoresizingMaskIntoConstraints = YES; + if (!CGRectEqualToRect(_nativeView.frame, self.bounds)) { + _nativeView.frame = self.bounds; + } + _nativeView.autoresizingMask = + UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + return; + } + + if (!CGRectEqualToRect(_nativeView.frame, self.bounds)) { + _nativeView.frame = self.bounds; + } + + if (_nativeViewHostConstraints != nil) { + BOOL hasInactiveConstraint = NO; + for (NSLayoutConstraint* constraint in _nativeViewHostConstraints) { + if (!constraint.active) { + hasInactiveConstraint = YES; + break; + } + } + if (hasInactiveConstraint) { + [NSLayoutConstraint activateConstraints:_nativeViewHostConstraints]; + } + return; + } + + _nativeView.translatesAutoresizingMaskIntoConstraints = NO; + _nativeViewHostConstraints = [[NSArray alloc] initWithObjects: + [_nativeView.topAnchor constraintEqualToAnchor:self.topAnchor], + [_nativeView.bottomAnchor constraintEqualToAnchor:self.bottomAnchor], + [_nativeView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor], + [_nativeView.trailingAnchor constraintEqualToAnchor:self.trailingAnchor], + nil]; + [NSLayoutConstraint activateConstraints:_nativeViewHostConstraints]; +} + +- (void)layoutHostedViewControllerViewIfNeeded { + if (_viewController == nil || _nativeView != _viewController.view || + _nativeView.superview != self) { + return; + } + + [self applyNativeViewLayoutMode]; + [_nativeView setNeedsLayout]; + [_nativeView layoutIfNeeded]; +} + +- (void)setPinNativeViewToHost:(BOOL)pinNativeViewToHost { + if (_pinNativeViewToHost == pinNativeViewToHost) { + return; + } + + _pinNativeViewToHost = pinNativeViewToHost; + [self applyNativeViewLayoutMode]; + [self layoutHostedViewControllerViewIfNeeded]; + [self setNeedsLayout]; +} + +- (void)setDetachedChildrenContentOffsetX:(CGFloat)detachedChildrenContentOffsetX { + if (_detachedChildrenContentOffsetX == detachedChildrenContentOffsetX) { + return; + } + + _detachedChildrenContentOffsetX = detachedChildrenContentOffsetX; + [self invalidateDetachedChildrenLayoutSnapshot]; + [self invalidateDetachedChildrenDisplaySnapshot]; + [self setNeedsLayout]; +} + +- (void)setDetachedChildrenContentOffsetY:(CGFloat)detachedChildrenContentOffsetY { + if (_detachedChildrenContentOffsetY == detachedChildrenContentOffsetY) { + return; + } + + _detachedChildrenContentOffsetY = detachedChildrenContentOffsetY; + [self invalidateDetachedChildrenLayoutSnapshot]; + [self invalidateDetachedChildrenDisplaySnapshot]; + [self setNeedsLayout]; +} + +- (void)setExternalDetachedChildrenOwner:(BOOL)externalDetachedChildrenOwner { + if (_externalDetachedChildrenOwner == externalDetachedChildrenOwner) { + return; + } + + _externalDetachedChildrenOwner = externalDetachedChildrenOwner; + [self invalidateHostReadySnapshot]; + [self setNeedsLayout]; +} + +- (void)setMountChildrenDirectlyToChildrenView:(BOOL)mountChildrenDirectlyToChildrenView { + if (_mountChildrenDirectlyToChildrenView == mountChildrenDirectlyToChildrenView) { + return; + } + + _mountChildrenDirectlyToChildrenView = mountChildrenDirectlyToChildrenView; + [self detachDetachedChildrenTouchHandler]; + _detachedTouchSentinel.owner = nil; + [_detachedTouchSentinel removeFromSuperview]; + [_detachedTouchSentinel release]; + _detachedTouchSentinel = nil; + [self invalidateDetachedChildrenLayoutSnapshot]; + [self invalidateDetachedChildrenDisplaySnapshot]; + [self invalidateHostReadySnapshot]; + [self moveReactSubviewsToChildrenView]; + [self refreshDetachedChildrenHost]; + [self setNeedsLayout]; + [self notifyHostReadyIfNeeded]; +} + +- (void)setLayoutDirectChildrenToChildrenViewBounds:(BOOL)layoutDirectChildrenToChildrenViewBounds { + if (_layoutDirectChildrenToChildrenViewBounds == layoutDirectChildrenToChildrenViewBounds) { + return; + } + + _layoutDirectChildrenToChildrenViewBounds = layoutDirectChildrenToChildrenViewBounds; + [self invalidateDetachedChildrenLayoutSnapshot]; + [self setNeedsLayout]; + [self refreshDetachedChildrenHost]; +} + +- (void)setNativeView:(UIView*)nativeView { + if (_nativeView == nativeView) { + return; + } + + [self setNeedsUIKitHostRefreshAfterNativeAttachment]; + const BOOL nextNativeViewIsDetachedControllerView = + _detachControllerView && _viewController != nil && nativeView == _viewController.view; + if (NativeScriptHostedViewOwner(_nativeView) == self) { + NativeScriptSetHostedViewOwner(_nativeView, nil); + } + [self deactivateNativeViewHostConstraints]; + if (!(_detachControllerView && _viewController != nil && _nativeView == _viewController.view)) { + [_nativeView removeFromSuperview]; + } + [_nativeView release]; + _nativeView = nil; + + if (nativeView == nil) { + return; + } + + _nativeView = [nativeView retain]; + NativeScriptSetHostedViewOwner(_nativeView, self); + const BOOL nextNativeViewIsExternallyWindowOwned = + nextNativeViewIsDetachedControllerView && nativeView.superview != nil && + nativeView.superview != self && nativeView.window != nil; + if (nextNativeViewIsExternallyWindowOwned) { + [self moveReactSubviewsToChildrenView]; + [self refreshDetachedChildrenHost]; + [_nativeView setNeedsDisplay]; + [_nativeView.layer setNeedsDisplay]; + [self setNeedsLayout]; + [self notifyHostReadyIfNeeded]; + [self refreshUIKitHostAfterNativeAttachment]; + return; + } + [_nativeView removeFromSuperview]; + [super insertSubview:_nativeView atIndex:0]; + [self applyNativeViewLayoutMode]; + [self moveReactSubviewsToChildrenView]; + [_nativeView setNeedsDisplay]; + [_nativeView.layer setNeedsDisplay]; + [self setNeedsLayout]; + [self notifyHostReadyIfNeeded]; + [self refreshUIKitHostAfterNativeAttachment]; +} + +// Upstream react-native-screens hosting shape: the Fabric-managed host view +// IS the controller's view, so UIKit containment (push/pop/present) moves the +// mounted React children wholesale and no child reparenting, readiness +// certification, or repair walking is required. The controller retains its +// view (this container) while the container retains the controller; the cycle +// is broken by restoreAdoptedControllerViewIfNeeded from prepareForRecycle, +// controller replacement, or the adopt flag turning off. +- (void)adoptAsControllerViewIfNeeded { + if (!_adoptHostViewAsControllerView || _viewController == nil) { + return; + } + if (_viewController.viewLoaded && _viewController.view == self) { + return; + } + _viewController.view = self; + // If the controller is already the visible top of a navigation controller, + // UIKit captured its PREVIOUS view (e.g. a throwaway created before adoption + // completed, as happens for the root installed via setViewControllers). Just + // reassigning controller.view does not swap what UIKit displays, so the + // adopted container stays orphaned/blank. Force a re-display by re-setting + // the navigation stack. Gated on window==nil so it runs only until UIKit + // actually displays us (then this is a no-op). + UINavigationController* nav = _viewController.navigationController; + if (nav != nil && nav.topViewController == _viewController && + self.window == nil && nav.view.window != nil) { + NSArray* vcs = nav.viewControllers; + [nav setViewControllers:vcs animated:NO]; + } + [self notifyHostReadyIfNeeded]; +} + +- (void)restoreAdoptedControllerViewIfNeeded { + if (_viewController == nil || !_viewController.viewLoaded || + _viewController.view != self) { + return; + } + UIView* replacement = [[UIView alloc] initWithFrame:self.frame]; + replacement.autoresizingMask = + UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + _viewController.view = replacement; + [replacement release]; +} + +// Once a UINavigationController owns the adopted controller, UIKit (not +// Fabric) is responsible for sizing this view as part of push/pop +// transitions and safe-area/navigation-bar layout. Fabric must stop forcing +// its own frame in that state or the two layout systems fight each other. +// +// BUT: only defer to UIKit once UIKit has ACTUALLY sized this container to a +// positive-size frame. A root screen set via -setViewControllers: (no push +// transition to lay it out) would otherwise stay 0x0 forever and render blank, +// because navigationController != nil the instant it becomes the root. While +// the frame is still empty, let Fabric seed the size from its Yoga bounds; +// once UIKit owns a real frame we stop fighting it (push/pop transitions and +// nav-bar/safe-area layout all leave a positive frame, so this only unblocks +// the initial cold-size case). +- (BOOL)isAdoptedControllerViewOwnedByNavigationController { + return _adoptHostViewAsControllerView && _viewController != nil && + _viewController.navigationController != nil && + !CGRectIsEmpty(self.frame); +} + +// PURE predicate (no side effects — unlike shouldDeferContainerFrameToNavigationController, +// and without isAdoptedControllerViewOwnedByNavigationController's empty-frame +// guard). RNS parity (RNSScreen.mm updateLayoutMetrics ~1348-1371): once a +// UINavigationController owns the adopted controller, UIKit — not Yoga — is +// authoritative for this container's frame, from the instant adoption+nav +// membership are both true (not just once UIKit has produced a non-empty +// frame). Used by the Fabric ComponentView to decide whether to apply Yoga's +// resolved layout metrics to the view at all. +- (BOOL)containerFrameIsUIKitDrivenByNavigationController { + return _adoptHostViewAsControllerView && _viewController != nil && + _viewController.navigationController != nil; +} + +// Returns YES if the Fabric host should stop applying its own frame (UIKit +// owns it). When the adopted controller is under a navigation controller but +// UIKit has not sized us yet — e.g. the root installed via +// setViewControllers:animated:NO, which gets no push-transition layout pass — +// seed the frame from the navigation controller's content bounds so the +// screen is never left 0x0/blank; UIKit refines it on its next layout. +- (BOOL)shouldDeferContainerFrameToNavigationController { + if (!_adoptHostViewAsControllerView || _viewController == nil) { + return NO; + } + // Self-heal: this container is marked adopted and owns a controller, but the + // controller's view is no longer us (a restore swapped in a throwaway view, + // or the controller was re-driven). Re-assert adoption so the controller + // displays our real (content-bearing) container instead of an empty view. + if (_viewController.isViewLoaded && _viewController.view != self) { + [self adoptAsControllerViewIfNeeded]; + [self setNeedsLayout]; + } + if (_viewController.navigationController == nil) { + return NO; + } + if (CGRectIsEmpty(self.frame)) { + UIView* navView = _viewController.navigationController.view; + if (navView != nil && !CGRectIsEmpty(navView.bounds)) { + self.frame = navView.bounds; + self.autoresizingMask = + UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + [self setNeedsLayout]; + } + } + // NOTE: do NOT push the adopted size feedback from here. This predicate is + // reachable from pointInside:/hitTest: (touch delivery), and the size + // feedback push now commits shadow-tree state SYNCHRONOUSLY + // (unstable_Immediate) to match RNSScreen -updateBounds; firing that during + // touch delivery would run a Fabric commit mid-gesture. RNS only pushes its + // size feedback from -layoutSubviews (see updateBounds's caller). The + // empty-frame seeding above already calls [self setNeedsLayout], so + // -layoutSubviews will perform the push on this container's next layout + // pass regardless. + return YES; +} + +- (void)setAdoptHostViewAsControllerView:(BOOL)adoptHostViewAsControllerView { + if (_adoptHostViewAsControllerView == adoptHostViewAsControllerView) { + return; + } + if (!adoptHostViewAsControllerView) { + [self restoreAdoptedControllerViewIfNeeded]; + } + _adoptHostViewAsControllerView = adoptHostViewAsControllerView; + if (adoptHostViewAsControllerView) { + [self adoptAsControllerViewIfNeeded]; + } +} + +- (void)setViewController:(UIViewController*)viewController { + if (_viewController == viewController) { + return; + } + + [self setNeedsUIKitHostRefreshAfterNativeAttachment]; + [self restoreAdoptedControllerViewIfNeeded]; + [self detachViewControllerIfOwnedByHost]; + [_viewController release]; _viewController = [viewController retain]; + _attachedViewControllerParent = nil; + if (_adoptHostViewAsControllerView) { + // Adoption replaces the internal containment machinery: the container is + // the controller's view, so hosting the controller view inside the + // container (attachNativeView/detachControllerView) does not apply. + [self adoptAsControllerViewIfNeeded]; + [self setNeedsLayout]; + [self notifyHostReadyIfNeeded]; + return; + } + if (_detachControllerFromParent) { + [self detachViewController]; + _attachedViewControllerParent = nil; + } if (_detachControllerView) { if (_viewController != nil && _nativeView == _viewController.view) { [self setNativeView:nil]; } return; } - if (_nativeViewHandle.length == 0) { - [self setNativeView:_viewController.view]; + if (_attachNativeView && _nativeViewHandle.length == 0) { + [self setNativeView:_viewController.view]; + } + // Defer containment until didMove/layout/update refreshes so all host props, + // especially detachControllerFromParent, have been applied for this commit. + [self layoutHostedViewControllerViewIfNeeded]; + [self setNeedsLayout]; + [self notifyHostReadyIfNeeded]; +} + +- (void)attachViewControllerIfPossible { + if (!_attachControllerToParent || _detachControllerFromParent || _detachControllerView || + _viewController == nil || _viewController.presentingViewController != nil || + _viewController.isBeingPresented || _viewController.isBeingDismissed || self.window == nil) { + return; + } + + UIViewController* parent = NativeScriptNearestViewController(self, _viewController); + UIViewController* rootController = self.window.rootViewController; + if (parent == nil || parent == _viewController) { + return; + } + + if (_viewController.parentViewController == parent && + (rootController == nil || + NativeScriptControllerHierarchyContainsController(rootController, _viewController))) { + return; + } + + if (_viewController.parentViewController != nil) { + if (_attachedViewControllerParent == nil || + _viewController.parentViewController != _attachedViewControllerParent) { + return; + } + [self detachViewControllerIfOwnedByHost]; + if (_viewController.parentViewController != nil) { + return; + } + } + + UIView* hostedViewToReinsert = nil; + NSUInteger hostedViewIndex = NSNotFound; + if (_nativeView.superview == self && + NativeScriptHostedViewContainsControllerView(_nativeView, _viewController)) { + hostedViewToReinsert = [_nativeView retain]; + hostedViewIndex = [self.subviews indexOfObject:hostedViewToReinsert]; + [self deactivateNativeViewHostConstraints]; + [hostedViewToReinsert removeFromSuperview]; + } + + const BOOL shouldForwardAppearance = + hostedViewToReinsert == nil && NativeScriptShouldForwardControllerAppearance(_viewController); + if (shouldForwardAppearance) { + [_viewController beginAppearanceTransition:YES animated:NO]; + } + + [parent addChildViewController:_viewController]; + _attachedViewControllerParent = parent; + if (hostedViewToReinsert != nil) { + NSUInteger targetIndex = + hostedViewIndex == NSNotFound ? 0 : MIN(hostedViewIndex, self.subviews.count); + [super insertSubview:hostedViewToReinsert atIndex:targetIndex]; + } + [self layoutHostedViewControllerViewIfNeeded]; + [_viewController didMoveToParentViewController:parent]; + [self layoutHostedViewControllerViewIfNeeded]; + + if (shouldForwardAppearance) { + [_viewController endAppearanceTransition]; + } + [hostedViewToReinsert release]; +} + +- (void)detachViewController { + if (_viewController == nil || _viewController.parentViewController == nil) { + return; + } + + UIView* hostedViewToReinsert = nil; + NSUInteger hostedViewIndex = NSNotFound; + if (_nativeView.superview == self && + NativeScriptHostedViewContainsControllerView(_nativeView, _viewController)) { + hostedViewToReinsert = [_nativeView retain]; + hostedViewIndex = [self.subviews indexOfObject:hostedViewToReinsert]; + } + + const BOOL shouldForwardAppearance = + hostedViewToReinsert == nil && NativeScriptShouldForwardControllerAppearance(_viewController); + if (shouldForwardAppearance) { + [_viewController beginAppearanceTransition:NO animated:NO]; + } + + [_viewController willMoveToParentViewController:nil]; + [hostedViewToReinsert removeFromSuperview]; + [_viewController removeFromParentViewController]; + if (hostedViewToReinsert != nil) { + NSUInteger targetIndex = + hostedViewIndex == NSNotFound ? 0 : MIN(hostedViewIndex, self.subviews.count); + [super insertSubview:hostedViewToReinsert atIndex:targetIndex]; + } + + if (shouldForwardAppearance) { + [_viewController endAppearanceTransition]; + } + [hostedViewToReinsert release]; +} + +- (void)detachViewControllerIfOwnedByHost { + if (_viewController == nil || _attachedViewControllerParent == nil) { + return; + } + + if (_viewController.parentViewController != _attachedViewControllerParent) { + _attachedViewControllerParent = nil; + return; + } + + [self detachViewController]; + _attachedViewControllerParent = nil; +} + +- (void)dismissViewControllerPresentationIfNeeded { + if (_viewController == nil) { + return; + } + + UIViewController* presentedController = _viewController.presentedViewController; + if (presentedController != nil && !presentedController.isBeingDismissed) { + [_viewController dismissViewControllerAnimated:NO completion:nil]; + } + + UIViewController* presentationController = _viewController; + UIViewController* navigationController = _viewController.navigationController; + if (navigationController != nil && navigationController.presentingViewController != nil) { + presentationController = navigationController; + } + + if (presentationController.presentingViewController != nil && + !presentationController.isBeingDismissed) { + [presentationController dismissViewControllerAnimated:NO completion:nil]; + } +} + +- (void)moveReactSubviewsToChildrenView { + if (_childrenView == nil) { + return; + } + + NSArray* subviews = [self.subviews copy]; + for (UIView* subview in subviews) { + if (subview == _nativeView || subview == _childrenView) { + continue; + } + if (_collectChildren) { + [subview removeFromSuperview]; + if (![_collectedChildComponentViews containsObject:subview]) { + [_collectedChildComponentViews addObject:subview]; + } + continue; + } + [_childrenView addSubview:subview]; + } + [subviews release]; + if (_collectChildren) { + [self detachDetachedChildrenTouchHandler]; + [self invalidateDetachedChildrenLayoutSnapshot]; + [self invalidateDetachedChildrenDisplaySnapshot]; + [self invalidateHostReadySnapshot]; + [self refreshCollectedChildrenHostIfNeeded]; + return; + } + if (_mountChildrenDirectlyToChildrenView) { + if (_layoutDirectChildrenToChildrenViewBounds) { + NativeScriptLayoutHostedSubviewChain(_childrenView, _detachedTouchSentinel, 0); + } + [self detachDetachedChildrenTouchHandler]; + [self invalidateDetachedChildrenLayoutSnapshot]; + [self invalidateDetachedChildrenDisplaySnapshot]; + [self invalidateHostReadySnapshot]; + [self notifyHostReadyIfNeeded]; + return; + } + [self layoutDetachedChildrenViewSubviewsIfNeeded]; + [self invalidateDetachedChildrenDisplay]; + [self installDetachedChildrenTouchSentinelIfNeeded]; + [self attachDetachedChildrenTouchHandlerIfNeeded]; + [self notifyHostReadyIfNeeded]; +} + +- (void)insertSubview:(UIView*)view atIndex:(NSInteger)index { + if (_childrenView != nil && view != _nativeView && view != _childrenView) { + if (_collectChildren) { + if (view.superview != nil) { + [view removeFromSuperview]; + } + if (![_collectedChildComponentViews containsObject:view]) { + NSUInteger targetIndex = + MIN(static_cast(MAX(index, 0)), _collectedChildComponentViews.count); + [_collectedChildComponentViews insertObject:view atIndex:targetIndex]; + } + [self detachDetachedChildrenTouchHandler]; + [self invalidateDetachedChildrenLayoutSnapshot]; + [self invalidateDetachedChildrenDisplaySnapshot]; + [self invalidateHostReadySnapshot]; + [self refreshCollectedChildrenHostIfNeeded]; + return; + } + + NSUInteger targetIndex = + MIN(static_cast(MAX(index, 0)), _childrenView.subviews.count); + [_childrenView insertSubview:view atIndex:targetIndex]; + if (_mountChildrenDirectlyToChildrenView) { + if (_layoutDirectChildrenToChildrenViewBounds) { + NativeScriptLayoutHostedSubviewChain(_childrenView, _detachedTouchSentinel, 0); + } + [self detachDetachedChildrenTouchHandler]; + [self invalidateDetachedChildrenLayoutSnapshot]; + [self invalidateDetachedChildrenDisplaySnapshot]; + [self invalidateHostReadySnapshot]; + [self notifyHostReadyIfNeeded]; + return; + } + [self layoutDetachedChildrenViewSubviewsIfNeeded]; + [self invalidateDetachedChildrenDisplay]; + [self installDetachedChildrenTouchSentinelIfNeeded]; + [self attachDetachedChildrenTouchHandlerIfNeeded]; + [self notifyHostReadyIfNeeded]; + return; + } + [super insertSubview:view atIndex:index]; + [self notifyHostReadyIfNeeded]; +} + +- (NSArray*)collectedChildComponentViews { + return _collectedChildComponentViews; +} + +- (void)recordFabricChildComponentViewMounted:(UIView*)view index:(NSInteger)index { + if (view == nil || view == self || view == _nativeView || view == _childrenView || + view == _detachedTouchSentinel) { + return; + } + + if ([_fabricMountedChildComponentViews containsObject:view]) { + [_fabricMountedChildComponentViews removeObject:view]; + } + + NSUInteger targetIndex = + MIN(static_cast(MAX(index, 0)), _fabricMountedChildComponentViews.count); + [_fabricMountedChildComponentViews insertObject:view atIndex:targetIndex]; +} + +- (void)recordFabricChildComponentViewUnmounted:(UIView*)view { + if (view == nil) { + return; + } + + [_fabricMountedChildComponentViews removeObject:view]; +} + +- (void)clearFabricChildComponentViewRecords { + [_fabricMountedChildComponentViews removeAllObjects]; +} + +- (void)clearFabricRelocationRecordForUnmountedChildComponentView:(UIView*)view { + NativeScriptClearFabricRelocationRecord(view); +} + +- (void)markFabricChildComponentViewTagsPendingUnmountForCurrentTransaction: + (NSSet*)tags { + if (tags.count == 0) { + return; + } + if (_fabricPendingUnmountTagsForCurrentTransaction == nil) { + _fabricPendingUnmountTagsForCurrentTransaction = [NSMutableSet new]; + } + [_fabricPendingUnmountTagsForCurrentTransaction unionSet:tags]; +} + +- (void)clearFabricChildComponentViewTagsPendingUnmountForCurrentTransaction { + [_fabricPendingUnmountTagsForCurrentTransaction removeAllObjects]; +} + +- (void)restoreFabricChildComponentViewsForUnmount:(UIView*)view index:(NSInteger)index { + UIView* expectedSuperview = nil; + if (_collectChildren) { + expectedSuperview = _childrenView ?: self; + } else if (view != nil && view.superview == _childrenView) { + expectedSuperview = _childrenView; + } else { + expectedSuperview = self; + } + + NativeScriptRestoreFabricChildrenForUnmount(expectedSuperview, view, index, + _fabricPendingUnmountTagsForCurrentTransaction, + _fabricMountedChildComponentViews); +} + +- (BOOL)unmountCollectedChildComponentView:(UIView*)view { + if (view == nil || ![_collectedChildComponentViews containsObject:view]) { + return NO; + } + + [view retain]; + [_collectedChildComponentViews removeObject:view]; + [view removeFromSuperview]; + NativeScriptClearFabricRelocationRecord(view); + [view release]; + [self invalidateHostReadySnapshot]; + [self invalidateDetachedChildrenDisplaySnapshot]; + [self refreshCollectedChildrenHostIfNeeded]; + return YES; +} + +- (void)refreshCollectedChildrenHostIfNeeded { + if (_collectChildren && _hostId.length > 0) { + [self runUIKitHostLifecycle:@"refresh" + transactionJson:[self fabricTransactionJsonWithModifiedChildren:YES + modifiedProps:NO]]; + } +} + +- (void)layoutDetachedChildrenViewSubviewsIfNeeded { + [self layoutDetachedChildrenViewSubviewsAndReturnMutation]; +} + +- (void)notifyFabricTransactionCommitted { + [self notifyFabricTransactionCommittedWithModifiedChildren:NO modifiedProps:NO]; +} + +- (NSString*)fabricTransactionJsonWithModifiedChildren:(BOOL)hasModifiedChildren + modifiedProps:(BOOL)hasModifiedProps { + return [self fabricTransactionJsonWithModifiedChildren:hasModifiedChildren + modifiedProps:hasModifiedProps + mutations:nil]; +} + +- (NSString*)fabricTransactionJsonWithModifiedChildren:(BOOL)hasModifiedChildren + modifiedProps:(BOOL)hasModifiedProps + mutations:(NSArray*>*)mutations { + NSDictionary* transaction = @{ + @"children" : [self fabricMountedChildrenSnapshot], + @"hasModifiedChildren" : @(hasModifiedChildren), + @"hasModifiedProps" : @(hasModifiedProps), + @"mutations" : mutations ?: @[], + // SEAM D STAGE 0 follow-up: surface the shared delivery token (bumped + // exactly-once per ACTUAL delivery by the notifyFabricTransactionCommitted + // funnel below) so JS-side host consumers can derive an O(1) + // per-host commit-sequence predicate instead of re-deriving readiness + // by walking view state on every call. + @"deliveryToken" : @(_fabricTransactionDeliveryToken), + }; + NSError* error = nil; + NSData* transactionData = + [NSJSONSerialization dataWithJSONObject:transaction options:0 error:&error]; + NSString* transactionJson = transactionData != nil + ? [[[NSString alloc] initWithData:transactionData encoding:NSUTF8StringEncoding] autorelease] + : nil; + return transactionJson; +} + +- (void)notifyFabricTransactionCommittedWithModifiedChildren:(BOOL)hasModifiedChildren + modifiedProps:(BOOL)hasModifiedProps { + [self notifyFabricTransactionCommittedWithModifiedChildren:hasModifiedChildren + modifiedProps:hasModifiedProps + mutations:nil]; +} + +- (void)notifyFabricTransactionCommittedWithModifiedChildren:(BOOL)hasModifiedChildren + modifiedProps:(BOOL)hasModifiedProps + mutations:(NSArray*>*)mutations { + // SEAM D STAGE 0: this is the single funnel every producer of a + // `transactionCommitted` delivery converges on (ComponentView's + // mountingTransactionDidMount sync/async paths, its mount-op fallback, this + // class's props-revision path, the host-creation replay, and the + // no-mutation callers below). Bump the shared delivery token on every + // ACTUAL delivery so any other producer's still-pending deferred schedule + // (captured via -advanceFabricTransactionDeliveryToken before this one + // fired) observes a mismatch and no-ops instead of redelivering the same + // commit a second time. + _fabricTransactionDeliveryToken += 1; + NSArray*>* mountedChildren = [self fabricMountedChildrenSnapshot]; + NativeScriptFabricDebugLog(@"notifyTransaction owner=%p debug=%@ hostId=%@ created=%d modifiedChildren=%d modifiedProps=%d childCount=%lu", + self, + _debugName ?: @"", + _hostId ?: @"", + _hasCreatedUIKitHost, + hasModifiedChildren, + hasModifiedProps, + static_cast(mountedChildren.count)); + for (NSDictionary* event in mountedChildren) { + NativeScriptFabricDebugLog(@"notifyTransaction child owner=%p debug=%@ hostId=%@ event={%@}", + self, + _debugName ?: @"", + _hostId ?: @"", + NativeScriptFabricDebugChildEventSummary(event)); + } + NSString* transactionJson = + [self fabricTransactionJsonWithModifiedChildren:hasModifiedChildren + modifiedProps:hasModifiedProps + mutations:mutations]; + [self runUIKitHostLifecycle:@"transactionCommitted" transactionJson:transactionJson]; +} + +- (BOOL)layoutDetachedChildrenViewSubviewsAndReturnMutation { + if (_childrenView == nil || _mountChildrenDirectlyToChildrenView) { + return NO; + } + + NSString* layoutKey = + NativeScriptDetachedChildrenLayoutSnapshotKey(_childrenView, _detachedTouchSentinel); + if ([_lastDetachedChildrenLayoutKey isEqualToString:layoutKey]) { + return NO; + } + + BOOL didMutate = NO; + CGRect bounds = _childrenView.bounds; + const CGPoint contentOffset = + CGPointMake(_detachedChildrenContentOffsetX, _detachedChildrenContentOffsetY); + if (!CGPointEqualToPoint(bounds.origin, contentOffset)) { + bounds.origin = contentOffset; + _childrenView.bounds = bounds; + didMutate = YES; + } + for (UIView* subview in _childrenView.subviews) { + if (subview == _detachedTouchSentinel) { + if (!CGRectEqualToRect(subview.frame, CGRectZero)) { + subview.frame = CGRectZero; + didMutate = YES; + } + continue; + } + + // A subview under Auto Layout ownership (translatesAutoresizingMaskIntoConstraints + // == NO) manages its own frame via constraints. This fill pass must never write + // its frame or stamp a flexible autoresizing mask onto it: doing so both fights + // Auto Layout for the initial size AND — once a flexible mask is set — makes + // UIKit's own -[UIView(Geometry) _resizeWithOldSuperviewSize:] re-stretch the + // view on every subsequent superview bounds change (the "decays after a few + // interactions" behavior). Engine-owned slot containers (ScreenFooter, + // FullWindowOverlay) set TAMIC = NO precisely to claim this exemption. + if (!subview.translatesAutoresizingMaskIntoConstraints) { + continue; + } + + if (_preserveDetachedChildrenLayout) { + continue; + } + + BOOL didMutateSubview = NO; + if (!CGRectEqualToRect(subview.frame, bounds)) { + subview.frame = bounds; + didMutateSubview = YES; + } + const UIViewAutoresizing flexibleSizeMask = + UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + if (subview.autoresizingMask != flexibleSizeMask) { + subview.autoresizingMask = flexibleSizeMask; + didMutateSubview = YES; + } + if (didMutateSubview) { + [subview setNeedsLayout]; + didMutate = YES; + } + didMutate = + NativeScriptLayoutHostedSubviewChain(subview, _detachedTouchSentinel, 0) || didMutate; + } + + [_lastDetachedChildrenLayoutKey release]; + _lastDetachedChildrenLayoutKey = [NativeScriptDetachedChildrenLayoutSnapshotKey( + _childrenView, _detachedTouchSentinel) copy]; + return didMutate; +} + +- (BOOL)refreshDetachedChildrenHost { + if (_childrenView == nil) { + return NO; + } + + if (_mountChildrenDirectlyToChildrenView) { + if (_layoutDirectChildrenToChildrenViewBounds) { + NativeScriptLayoutHostedSubviewChain(_childrenView, _detachedTouchSentinel, 0); + } + [self detachDetachedChildrenTouchHandler]; + [self invalidateDetachedChildrenDisplayIfNeeded]; + [self invalidateHostReadySnapshot]; + [self notifyHostReadyIfNeeded]; + return NativeScriptChildrenViewHasVisibleChild(_childrenView, _detachedTouchSentinel, self); + } + + [self layoutDetachedChildrenViewSubviewsIfNeeded]; + [self installDetachedChildrenTouchSentinelIfNeeded]; + [self attachDetachedChildrenTouchHandlerIfNeeded]; + [self updateDetachedChildrenTouchHandlerOrigin]; + [self invalidateDetachedChildrenDisplayIfNeeded]; + [self notifyHostReadyIfNeeded]; + + return NativeScriptChildrenViewHasVisibleChild(_childrenView, _detachedTouchSentinel, self); +} + +- (void)refreshDetachedChildrenSentinelAttachment { + if (_childrenView == nil) { + return; + } + + if (_mountChildrenDirectlyToChildrenView) { + if (_layoutDirectChildrenToChildrenViewBounds) { + NativeScriptLayoutHostedSubviewChain(_childrenView, _detachedTouchSentinel, 0); + } + [self detachDetachedChildrenTouchHandler]; + [self invalidateDetachedChildrenDisplaySnapshot]; + return; + } + + [self layoutDetachedChildrenViewSubviewsIfNeeded]; + [self attachDetachedChildrenTouchHandlerIfNeeded]; + [self updateDetachedChildrenTouchHandlerOrigin]; + [self invalidateDetachedChildrenDisplayIfNeeded]; +} + +- (void)installDetachedChildrenTouchSentinelIfNeeded { + if (_childrenView == nil || _detachedTouchSentinel != nil || + _mountChildrenDirectlyToChildrenView) { + return; + } + + NativeScriptDetachedChildrenTouchSentinel* sentinel = + [[NativeScriptDetachedChildrenTouchSentinel alloc] initWithFrame:CGRectZero]; + sentinel.owner = self; + sentinel.hidden = YES; + sentinel.userInteractionEnabled = NO; + sentinel.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + _detachedTouchSentinel = sentinel; + [_childrenView addSubview:sentinel]; +} + +- (void)attachDetachedChildrenTouchHandlerIfNeeded { + if (_disableDetachedChildrenTouchHandler || _mountChildrenDirectlyToChildrenView) { + [self detachDetachedChildrenTouchHandler]; + return; + } + + if (_childrenView == nil) { + return; + } + + UIView* touchView = _childrenView; + const BOOL shouldUseNativeControllerTouchSurface = + _nativeView != nil && + _nativeView.window != nil && + NativeScriptHostedViewContainsControllerView(_nativeView, _viewController); + + if (shouldUseNativeControllerTouchSurface) { + touchView = _nativeView; + } + + if (NativeScriptGestureRecognizerHasActiveTouches(_detachedTouchHandler)) { + _detachedTouchHandlerWindow = _detachedTouchHandlerView.window; + [self updateDetachedChildrenTouchHandlerOrigin]; + if (NativeScriptTouchDebugEnabled()) { + NSLog(@"[NS_TOUCH_DEBUG] preserve active handler owner=%p current=%@ next=%@ handler=%@", + self, + NativeScriptTouchDebugViewSummary(_detachedTouchHandlerView), + NativeScriptTouchDebugViewSummary(touchView), + _detachedTouchHandler); + } + return; + } + + if (touchView.hidden || touchView.alpha <= 0.01 || touchView.window == nil) { + if (_detachedTouchHandler != nil && _detachedTouchHandlerView == touchView) { + _detachedTouchHandlerWindow = touchView.window; + [self updateDetachedChildrenTouchHandlerOrigin]; + if (NativeScriptTouchDebugEnabled()) { + NSLog(@"[NS_TOUCH_DEBUG] preserve hidden/window owner=%p touch=%@", self, + NativeScriptTouchDebugViewSummary(touchView)); + } + return; + } + if (NativeScriptTouchDebugEnabled()) { + NSLog(@"[NS_TOUCH_DEBUG] detach hidden/window owner=%p touch=%@", self, + NativeScriptTouchDebugViewSummary(touchView)); + } + [self detachDetachedChildrenTouchHandler]; + return; + } + + if (NativeScriptViewHasSurfaceTouchHandlerInAncestorChain(touchView, _detachedTouchHandler)) { + NativeScriptUpdateSurfaceTouchHandlerOriginsInAncestorChain(touchView, _detachedTouchHandler); + if (NativeScriptTouchDebugEnabled()) { + NSLog(@"[NS_TOUCH_DEBUG] skip attach ancestor handler owner=%p touch-chain=%@", + self, NativeScriptTouchDebugAncestorSummary(touchView)); + } + [self detachDetachedChildrenTouchHandler]; + return; + } + + touchView.userInteractionEnabled = YES; + + if (NativeScriptViewHasSurfaceTouchHandler(touchView, _detachedTouchHandler)) { + if (NativeScriptTouchDebugEnabled()) { + NSLog(@"[NS_TOUCH_DEBUG] skip attach own handler owner=%p touch=%@", self, + NativeScriptTouchDebugViewSummary(touchView)); + } + NativeScriptUpdateSurfaceTouchHandlerOrigins(touchView, _detachedTouchHandler); + [self detachDetachedChildrenTouchHandler]; + return; + } + + if (_detachedTouchHandler != nil) { + UIView* attachedTouchHandlerView = + NativeScriptGestureRecognizerAttachedView(_detachedTouchHandler); + if (_detachedTouchHandlerView == touchView && attachedTouchHandlerView == nil) { + if ([_detachedTouchHandler respondsToSelector:@selector(attachToView:)]) { + [_detachedTouchHandler attachToView:touchView]; + } else { + [touchView addGestureRecognizer:_detachedTouchHandler]; + } + _detachedTouchHandlerWindow = touchView.window; + [self updateDetachedChildrenTouchHandlerOrigin]; + if (NativeScriptTouchDebugEnabled()) { + NSLog(@"[NS_TOUCH_DEBUG] reattached detached handler owner=%p touch=%@ handler=%@", + self, NativeScriptTouchDebugViewSummary(touchView), _detachedTouchHandler); + } + return; + } + if (_detachedTouchHandlerView == touchView && + attachedTouchHandlerView == touchView && + NativeScriptViewHasGestureRecognizer(touchView, _detachedTouchHandler)) { + _detachedTouchHandlerWindow = touchView.window; + [self updateDetachedChildrenTouchHandlerOrigin]; + return; + } + + [self detachDetachedChildrenTouchHandler]; + } + + if (_detachedTouchHandler != nil) { + [self updateDetachedChildrenTouchHandlerOrigin]; + return; + } + +#if __has_include() + RCTSurfaceTouchHandler* surfaceTouchHandler = [RCTSurfaceTouchHandler new]; + [surfaceTouchHandler attachToView:touchView]; + _detachedTouchHandler = surfaceTouchHandler; + _detachedTouchHandlerView = [touchView retain]; + _detachedTouchHandlerWindow = touchView.window; + [self updateDetachedChildrenTouchHandlerOrigin]; + if (NativeScriptTouchDebugEnabled()) { + NSLog(@"[NS_TOUCH_DEBUG] attached detached handler owner=%p touch=%@ handler=%@", + self, NativeScriptTouchDebugViewSummary(touchView), surfaceTouchHandler); + } + return; +#endif +} + +- (void)updateDetachedChildrenTouchHandlerOrigin { +#if __has_include() + if (_detachedTouchHandler == nil || _detachedTouchHandlerView == nil || + ![_detachedTouchHandler isKindOfClass:RCTSurfaceTouchHandler.class]) { + return; + } + + CGPoint origin = CGPointZero; + if (_detachedTouchHandlerView.window != nil) { + origin = [_detachedTouchHandlerView convertPoint:CGPointZero + toView:_detachedTouchHandlerView.window]; + } + + ((RCTSurfaceTouchHandler*)_detachedTouchHandler).viewOriginOffset = origin; +#endif +} + +- (void)detachDetachedChildrenTouchHandler { + if (_detachedTouchHandler == nil || _detachedTouchHandlerView == nil) { + [_detachedTouchHandler release]; + _detachedTouchHandler = nil; + [_detachedTouchHandlerView release]; + _detachedTouchHandlerView = nil; + _detachedTouchHandlerWindow = nil; + return; + } + + UIView* attachedTouchHandlerView = + NativeScriptGestureRecognizerAttachedView(_detachedTouchHandler); + UIView* detachView = + attachedTouchHandlerView != nil ? attachedTouchHandlerView : _detachedTouchHandlerView; + + if ([_detachedTouchHandler respondsToSelector:@selector(detachFromView:)]) { + if (NativeScriptViewHasGestureRecognizer(detachView, _detachedTouchHandler)) { + [_detachedTouchHandler detachFromView:detachView]; + } + } + + [_detachedTouchHandler release]; + _detachedTouchHandler = nil; + [_detachedTouchHandlerView release]; + _detachedTouchHandlerView = nil; + _detachedTouchHandlerWindow = nil; +} + +- (BOOL)hostedContentPointInside:(CGPoint)point withEvent:(UIEvent*)event { + if ([super pointInside:point withEvent:event] && ![self shouldHideEmptyFabricHostWrapper]) { + return YES; + } + + if (_externalDetachedChildrenOwner) { + return NO; + } + + UIView* hostedViews[] = { _nativeView, _childrenView }; + for (NSUInteger index = 0; index < 2; index += 1) { + UIView* hostedView = hostedViews[index]; + if (hostedView == nil || hostedView == self || + (index == 1 && hostedView == _nativeView) || + hostedView.hidden || hostedView.alpha <= 0.01 || + !hostedView.userInteractionEnabled || hostedView.window == nil) { + continue; + } + + CGPoint hostedPoint = [hostedView convertPoint:point fromView:self]; + if (NativeScriptViewIsDescendantOfView(self, hostedView)) { + if (NativeScriptHostedOwnerViewPointInsideExcludingHost( + hostedView, self, hostedPoint, event, 0)) { + return YES; + } + continue; + } + if ([hostedView pointInside:hostedPoint withEvent:event]) { + return YES; + } + } + + if (self.window != nil) { + CGPoint windowPoint = [self convertPoint:point toView:self.window]; + UITabBar* tabBar = NativeScriptVisibleTabBarAtPoint(self.window, self.window, windowPoint); + if (tabBar != nil && NativeScriptViewIsDescendantOfView(tabBar, self)) { + return YES; + } + } + + return NO; +} + +- (UIView*)hostedContentHitTest:(CGPoint)point withEvent:(UIEvent*)event { + if (self.window != nil) { + CGPoint windowPoint = [self convertPoint:point toView:self.window]; + UIView* hostedViews[] = { _nativeView, _childrenView }; + for (NSUInteger index = 0; index < 2; index += 1) { + UIView* hostedView = hostedViews[index]; + if (hostedView == nil || hostedView == self || + (index == 1 && hostedView == _nativeView) || + hostedView.hidden || hostedView.alpha <= 0.01 || + !hostedView.userInteractionEnabled || hostedView.window == nil) { + continue; + } + + UIView* tabBarHitView = + NativeScriptHitTestTabBarAtPoint(hostedView, self.window, windowPoint, event); + if (tabBarHitView != nil) { + return tabBarHitView; + } + } + } + + UIView* hitView = [super hitTest:point withEvent:event]; + if (hitView != nil && hitView != self) { + const BOOL hitViewIsTransparentHostWrapper = + [hitView isKindOfClass:NativeScriptUIView.class] && + [static_cast(hitView) shouldHideEmptyFabricHostWrapper]; + const BOOL hitViewIsHostPlumbing = NativeScriptViewIsHostHitTestPlumbing(hitView); + if (hitViewIsTransparentHostWrapper || hitViewIsHostPlumbing) { + if (NativeScriptTouchDebugEnabled()) { + NSLog(@"[NS_TOUCH_DEBUG] skip super host plumbing owner=%p point=%@ hit-chain=%@", + self, NSStringFromCGPoint(point), NativeScriptTouchDebugAncestorSummary(hitView)); + } + hitView = nil; + } else { + if (NativeScriptTouchDebugEnabled()) { + NSLog(@"[NS_TOUCH_DEBUG] hit super child owner=%p point=%@ hit-chain=%@", + self, NSStringFromCGPoint(point), NativeScriptTouchDebugAncestorSummary(hitView)); + } + return hitView; + } + } + + if (_externalDetachedChildrenOwner) { + return hitView; + } + + UIView* hostedViews[] = { _nativeView, _childrenView }; + for (NSUInteger index = 0; index < 2; index += 1) { + UIView* hostedView = hostedViews[index]; + if (hostedView == nil || hostedView == self || + (index == 1 && hostedView == _nativeView) || + hostedView.hidden || hostedView.alpha <= 0.01 || + !hostedView.userInteractionEnabled || hostedView.window == nil) { + continue; + } + + CGPoint hostedPoint = [hostedView convertPoint:point fromView:self]; + UIView* hostedHitView = NativeScriptViewIsDescendantOfView(self, hostedView) + ? NativeScriptHostedOwnerViewHitTestExcludingHost(hostedView, self, hostedPoint, event, 0) + : [hostedView hitTest:hostedPoint withEvent:event]; + if (hostedHitView == nil || NativeScriptViewIsHostHitTestPlumbing(hostedHitView)) { + UIView* descendantHitView = + NativeScriptHitTestVisibleDescendantOutsideBounds(hostedView, hostedPoint, event, 0); + if (descendantHitView != nil) { + hostedHitView = descendantHitView; + } + } + if (hostedHitView != nil) { + if (NativeScriptViewIsHostHitTestPlumbing(hostedHitView)) { + if (NativeScriptTouchDebugEnabled()) { + NSLog(@"[NS_TOUCH_DEBUG] skip hosted host plumbing owner=%p point=%@ hosted=%@ hostedPoint=%@ hit-chain=%@", + self, + NSStringFromCGPoint(point), + NativeScriptTouchDebugViewSummary(hostedView), + NSStringFromCGPoint(hostedPoint), + NativeScriptTouchDebugAncestorSummary(hostedHitView)); + } + continue; + } + if (NativeScriptTouchDebugEnabled()) { + NSLog(@"[NS_TOUCH_DEBUG] hit hosted owner=%p point=%@ hosted=%@ hostedPoint=%@ hit-chain=%@", + self, + NSStringFromCGPoint(point), + NativeScriptTouchDebugViewSummary(hostedView), + NSStringFromCGPoint(hostedPoint), + NativeScriptTouchDebugAncestorSummary(hostedHitView)); + } + return hostedHitView; + } + } + + if (NativeScriptTouchDebugEnabled() && hitView != nil) { + NSLog(@"[NS_TOUCH_DEBUG] hit wrapper owner=%p point=%@ wrapper=%@", + self, NSStringFromCGPoint(point), NativeScriptTouchDebugViewSummary(hitView)); + } + if (hitView == self && + ([self shouldHideEmptyFabricHostWrapper] || NativeScriptViewIsHostHitTestPlumbing(self))) { + return nil; + } + return hitView; +} + +- (BOOL)hostedViewIsDetachedFromHostWrapper:(UIView*)hostedView { + if (self.window == nil || NativeScriptViewHasHiddenUIKitAncestor(self) || + hostedView == nil || hostedView == self || hostedView.window == nil || + hostedView.hidden || hostedView.alpha <= 0.01) { + return NO; + } + + return !NativeScriptViewIsDescendantOfView(hostedView, self); +} + +- (BOOL)hasVisibleSubviewMountedInHostWrapper { + for (UIView* subview in self.subviews) { + if (subview == _detachedTouchSentinel || subview.hidden || subview.alpha <= 0.01) { + continue; + } + return YES; + } + + return NO; +} + +- (BOOL)shouldHideEmptyFabricHostWrapper { + UIView* componentView = self.superview; + if (componentView != nil && (_childrenView == componentView || _nativeView == componentView)) { + return NO; + } + + if ([self hasVisibleSubviewMountedInHostWrapper]) { + return NO; + } + + const BOOL hasDetachedHostedContent = + [self hostedViewIsDetachedFromHostWrapper:_nativeView] || + (_childrenView != _nativeView && [self hostedViewIsDetachedFromHostWrapper:_childrenView]); + const BOOL hasExternalDetachedChildrenOwner = + _externalDetachedChildrenOwner && (_nativeView != nil || _childrenView != nil); + + return hasDetachedHostedContent || hasExternalDetachedChildrenOwner; +} + +- (NSArray*)accessibilityElements { + // Hit testing may need to route through this Fabric shell to reach a UIKit + // child that was moved under an external owner. Accessibility should not: + // UIKit already exposes that child through its real visible hierarchy, and + // re-exporting it here gives XCTest/VoiceOver two owners for the same subtree. + return [super accessibilityElements]; +} + +- (NSInteger)accessibilityElementCount { + NSArray* elements = [self accessibilityElements]; + if (elements.count > 0) { + return static_cast(elements.count); + } + + return [super accessibilityElementCount]; +} + +- (id)accessibilityElementAtIndex:(NSInteger)index { + NSArray* elements = [self accessibilityElements]; + if (index >= 0 && static_cast(index) < elements.count) { + return elements[static_cast(index)]; + } + + return [super accessibilityElementAtIndex:index]; +} + +- (NSInteger)indexOfAccessibilityElement:(id)element { + NSArray* elements = [self accessibilityElements]; + NSUInteger index = [elements indexOfObject:element]; + if (index != NSNotFound) { + return static_cast(index); } + + return [super indexOfAccessibilityElement:element]; +} + +- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent*)event { + return [self hostedContentPointInside:point withEvent:event]; +} + +- (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent*)event { + return [self hostedContentHitTest:point withEvent:event]; +} + +- (void)didMoveToWindow { + [super didMoveToWindow]; + [self mountUIKitHostIfNeeded]; [self attachViewControllerIfPossible]; + [self refreshUIKitHostAfterNativeAttachment]; + [self attachDetachedChildrenTouchHandlerIfNeeded]; + [self updateDetachedChildrenTouchHandlerOrigin]; + [self invalidateDetachedChildrenDisplayIfNeeded]; [self notifyHostReadyIfNeeded]; } -- (void)attachViewControllerIfPossible { - if (_detachControllerView || _viewController == nil || - _viewController.parentViewController != nil || self.window == nil) { +- (void)pushAdoptedSizeFeedbackIfNeeded { + // Adoption size feedback: once UIKit resolves the adopted screen's size, push + // it into the Fabric shadow tree so Yoga re-lays-out the hosted subtree to + // match (replaces the manual repair walk for adopted screens). This mirrors + // RNSScreen -updateBounds, including which size it feeds back — see below. + if (!_adoptHostViewAsControllerView) { return; } - - UIViewController* parent = NativeScriptNearestViewController(self); - if (parent == nil || parent == _viewController) { + UINavigationController* nav = _viewController.navigationController; + UIView* navView = nav.view; + UIWindow* window = navView.window ?: self.window; + // STEP 3c (self.bounds.size, matching RNSScreen.mm:147 exactly) was tried + // and reverted: it regressed the 5-launch matrix (2/5 failures reproducing + // the half-height/gray-gap symptom this whole fix targets). Root cause: + // self.bounds can be transiently half-height/short at the moment this fires + // (e.g. mid root-install/first-layout before UIKit has settled this + // container's real frame), and with Step 4 now refusing to let Yoga's + // layout metrics drive the frame while under a nav controller, there is no + // other mechanism to correct a bad size once it is baked into the shadow + // tree. The WINDOW's bounds are the stable full-screen size and don't have + // that failure mode, so keep preferring them (this is a fidelity refinement + // vs. RNSScreen, not the core synchronous-commit fix — see Step 3c note in + // the plan). + CGSize size = (window != nil && !CGRectIsEmpty(window.bounds)) + ? window.bounds.size + : ((navView != nil && !CGRectIsEmpty(navView.bounds)) + ? navView.bounds.size + : self.bounds.size); + if (size.width <= 0 || size.height <= 0) { return; } + UIView* component = _fabricComponentView; + if ([component + respondsToSelector:@selector(pushAdoptedContainerSizeToShadowTree:)]) { + [(id)component pushAdoptedContainerSizeToShadowTree:size]; + } + // RNS parity (RNSScreen.mm:155-156): request another layout pass on the + // navigation controller's view after pushing the size feedback. The state + // commit above triggers a later Fabric layout of the hosted subtree, but + // UIKit's own layout pass (nav bar / safe area / container view sizing) + // needs to be re-run too so it settles alongside the new content size. + // + // STAGE 2 (A2) — but NOT while a navigation transition is in flight. During + // the stock interactive edge back-swipe this method runs on every frame (via + // layoutSubviews), and forcing an extra layout pass on the CLOSING nav view + // each frame is a mid-transition write that re-strands the revealed screen + // (blank, no dim/parallax) — the native twin of the JS mid-transition layout + // writes §4F removed, which never covered this path. The shadow-tree size push + // above is kept (Yoga still re-lays the hosted subtree); only the redundant + // per-frame nav relayout is deferred until the transition settles. Plain + // property read (no bridged coordinator block), so no hang risk. + if (nav.transitionCoordinator == nil) { + [nav.view setNeedsLayout]; + } +} - UIView* hostedViewToReinsert = nil; - NSUInteger hostedViewIndex = NSNotFound; - if (_nativeView.superview == self && - NativeScriptHostedViewContainsControllerView(_nativeView, _viewController)) { - hostedViewToReinsert = [_nativeView retain]; - hostedViewIndex = [self.subviews indexOfObject:hostedViewToReinsert]; - [hostedViewToReinsert removeFromSuperview]; +- (void)layoutSubviews { + [super layoutSubviews]; + [self pushAdoptedSizeFeedbackIfNeeded]; + const BOOL ownsNativeViewAsSubview = _nativeView != nil && _nativeView.superview == self; + const BOOL didResizeNativeView = + ownsNativeViewAsSubview && !_pinNativeViewToHost && + !CGRectEqualToRect(_nativeView.frame, self.bounds); + if (didResizeNativeView) { + _nativeView.frame = self.bounds; + } + [self applyNativeViewLayoutMode]; + if (_pinNativeViewToHost || didResizeNativeView) { + [self layoutHostedViewControllerViewIfNeeded]; } + [self attachViewControllerIfPossible]; + if (_mountChildrenDirectlyToChildrenView && _layoutDirectChildrenToChildrenViewBounds) { + NativeScriptLayoutHostedSubviewChain(_childrenView, _detachedTouchSentinel, 0); + } + [self layoutDetachedChildrenViewSubviewsIfNeeded]; + [self installDetachedChildrenTouchSentinelIfNeeded]; + [self attachDetachedChildrenTouchHandlerIfNeeded]; + [self updateDetachedChildrenTouchHandlerOrigin]; + [self invalidateDetachedChildrenDisplayIfNeeded]; + [self notifyHostReadyIfNeeded]; +} - const BOOL shouldForwardAppearance = - hostedViewToReinsert == nil && NativeScriptShouldForwardControllerAppearance(_viewController); - if (shouldForwardAppearance) { - [_viewController beginAppearanceTransition:YES animated:NO]; +@end + +static BOOL NativeScriptRefreshOwner(NativeScriptUIView* owner) { + if (owner == nil) { + return NO; } - [parent addChildViewController:_viewController]; - if (hostedViewToReinsert != nil) { - NSUInteger targetIndex = - hostedViewIndex == NSNotFound ? 0 : MIN(hostedViewIndex, self.subviews.count); - [super insertSubview:hostedViewToReinsert atIndex:targetIndex]; + static NSMutableSet* refreshingOwners; + if (refreshingOwners == nil) { + refreshingOwners = [NSMutableSet new]; } - [_viewController didMoveToParentViewController:parent]; - if (shouldForwardAppearance) { - [_viewController endAppearanceTransition]; + NSValue* ownerKey = [NSValue valueWithNonretainedObject:owner]; + if ([refreshingOwners containsObject:ownerKey]) { + return NO; } - [hostedViewToReinsert release]; + + [refreshingOwners addObject:ownerKey]; + BOOL refreshedDetachedChildren = NO; + @try { + [owner attachViewControllerIfPossible]; + [owner runUIKitHostLifecycle:@"refresh" + transactionJson:[owner fabricTransactionJsonWithModifiedChildren:NO + modifiedProps:NO]]; + refreshedDetachedChildren = [owner refreshDetachedChildrenHost]; + } @finally { + [refreshingOwners removeObject:ownerKey]; + } + return refreshedDetachedChildren; } -- (void)detachViewController { - if (_detachControllerView || _viewController == nil || - _viewController.parentViewController == nil) { - return; +static BOOL NativeScriptInvalidateHostReadyOwner(NativeScriptUIView* owner) { + if (owner == nil) { + return NO; } - UIView* hostedViewToReinsert = nil; - NSUInteger hostedViewIndex = NSNotFound; - if (_nativeView.superview == self && - NativeScriptHostedViewContainsControllerView(_nativeView, _viewController)) { - hostedViewToReinsert = [_nativeView retain]; - hostedViewIndex = [self.subviews indexOfObject:hostedViewToReinsert]; + [owner invalidateHostReadySnapshot]; + [owner notifyHostReadyIfNeeded]; + return YES; +} + +static BOOL NativeScriptFlushOwnerDisplay(NativeScriptUIView* owner) { + if (owner == nil) { + return NO; } - const BOOL shouldForwardAppearance = - hostedViewToReinsert == nil && NativeScriptShouldForwardControllerAppearance(_viewController); - if (shouldForwardAppearance) { - [_viewController beginAppearanceTransition:NO animated:NO]; + return [owner flushDetachedChildrenDisplay]; +} + +static BOOL NativeScriptRefreshUIKitHostOwnersInAncestorChain(UIView* root) { + BOOL refreshed = NO; + UIView* current = root; + NSUInteger depth = 0; + + while (current != nil && depth < 24) { + if ([current isKindOfClass:NativeScriptUIView.class]) { + refreshed = NativeScriptRefreshOwner(static_cast(current)) || refreshed; + } + + refreshed = NativeScriptRefreshOwner(NativeScriptDetachedChildrenOwner(current)) || refreshed; + refreshed = NativeScriptRefreshOwner(NativeScriptHostedViewOwner(current)) || refreshed; + + current = current.superview; + depth += 1; } - [_viewController willMoveToParentViewController:nil]; - [hostedViewToReinsert removeFromSuperview]; - [_viewController removeFromParentViewController]; - if (hostedViewToReinsert != nil) { - NSUInteger targetIndex = - hostedViewIndex == NSNotFound ? 0 : MIN(hostedViewIndex, self.subviews.count); - [super insertSubview:hostedViewToReinsert atIndex:targetIndex]; + return refreshed; +} + +static BOOL NativeScriptFlushUIKitHostOwnersInAncestorChain(UIView* root) { + BOOL flushed = NO; + UIView* current = root; + NSUInteger depth = 0; + + while (current != nil && depth < 24) { + if ([current isKindOfClass:NativeScriptUIView.class]) { + flushed = NativeScriptFlushOwnerDisplay(static_cast(current)) || flushed; + } + + flushed = NativeScriptFlushOwnerDisplay(NativeScriptDetachedChildrenOwner(current)) || flushed; + flushed = NativeScriptFlushOwnerDisplay(NativeScriptHostedViewOwner(current)) || flushed; + + current = current.superview; + depth += 1; + } + + return flushed; +} + +static BOOL NativeScriptRefreshUIKitHostSubviews(UIView* root, NSUInteger depth) { + if (root == nil || depth > 24) { + return NO; + } + + BOOL refreshed = NO; + if ([root isKindOfClass:NativeScriptUIView.class]) { + refreshed = NativeScriptRefreshOwner(static_cast(root)) || refreshed; + } + + refreshed = NativeScriptRefreshOwner(NativeScriptDetachedChildrenOwner(root)) || refreshed; + refreshed = NativeScriptRefreshOwner(NativeScriptHostedViewOwner(root)) || refreshed; + + if ([root isKindOfClass:NativeScriptDetachedChildrenTouchSentinel.class]) { + NativeScriptDetachedChildrenTouchSentinel* sentinel = + static_cast(root); + refreshed = NativeScriptRefreshOwner(sentinel.owner) || refreshed; + } + + NSArray* subviews = [root.subviews copy]; + for (UIView* subview in subviews) { + refreshed = NativeScriptRefreshUIKitHostSubviews(subview, depth + 1) || refreshed; + } + [subviews release]; + + return refreshed; +} + +static BOOL NativeScriptFlushUIKitHostSubviews(UIView* root, NSUInteger depth) { + if (root == nil || depth > 24) { + return NO; } - if (shouldForwardAppearance) { - [_viewController endAppearanceTransition]; + BOOL flushed = NO; + if ([root isKindOfClass:NativeScriptUIView.class]) { + flushed = NativeScriptFlushOwnerDisplay(static_cast(root)) || flushed; } - [hostedViewToReinsert release]; -} -- (void)moveReactSubviewsToChildrenView { - if (_childrenView == nil) { - return; + flushed = NativeScriptFlushOwnerDisplay(NativeScriptDetachedChildrenOwner(root)) || flushed; + flushed = NativeScriptFlushOwnerDisplay(NativeScriptHostedViewOwner(root)) || flushed; + + if ([root isKindOfClass:NativeScriptDetachedChildrenTouchSentinel.class]) { + NativeScriptDetachedChildrenTouchSentinel* sentinel = + static_cast(root); + flushed = NativeScriptFlushOwnerDisplay(sentinel.owner) || flushed; } - NSArray* subviews = [self.subviews copy]; + NSArray* subviews = [root.subviews copy]; for (UIView* subview in subviews) { - if (subview == _nativeView || subview == _childrenView) { - continue; - } - [_childrenView addSubview:subview]; + flushed = NativeScriptFlushUIKitHostSubviews(subview, depth + 1) || flushed; } [subviews release]; - [self layoutDetachedChildrenViewSubviewsIfNeeded]; - [self installDetachedChildrenTouchSentinelIfNeeded]; - [self attachDetachedChildrenTouchHandlerIfNeeded]; - [self notifyHostReadyIfNeeded]; + + return flushed; } -- (void)insertSubview:(UIView*)view atIndex:(NSInteger)index { - if (_childrenView != nil && view != _nativeView && view != _childrenView) { - NSUInteger targetIndex = - MIN(static_cast(MAX(index, 0)), _childrenView.subviews.count); - [_childrenView insertSubview:view atIndex:targetIndex]; - [self layoutDetachedChildrenViewSubviewsIfNeeded]; - [self installDetachedChildrenTouchSentinelIfNeeded]; - [self attachDetachedChildrenTouchHandlerIfNeeded]; - [self notifyHostReadyIfNeeded]; - return; +static NativeScriptUIView* NativeScriptUIKitHostOwnerForView(UIView* view) { + if (view == nil) { + return nil; } - [super insertSubview:view atIndex:index]; - [self notifyHostReadyIfNeeded]; -} -- (void)layoutDetachedChildrenViewSubviewsIfNeeded { - if (_childrenView == nil) { - return; + if ([view isKindOfClass:NativeScriptUIView.class]) { + return static_cast(view); } - const CGRect bounds = _childrenView.bounds; - for (UIView* subview in _childrenView.subviews) { - if (subview == _detachedTouchSentinel) { - subview.frame = CGRectZero; - continue; - } + NativeScriptUIView* owner = NativeScriptDetachedChildrenOwner(view); + if (owner != nil) { + return owner; + } - subview.frame = bounds; - subview.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; - [subview setNeedsLayout]; - [subview layoutIfNeeded]; - NativeScriptLayoutHostedSubviewChain(subview, 0); + owner = NativeScriptHostedViewOwner(view); + if (owner != nil) { + return owner; } + + UIView* current = view.superview; + NSUInteger depth = 0; + while (current != nil && depth < 24) { + if ([current isKindOfClass:NativeScriptUIView.class]) { + return static_cast(current); + } + owner = NativeScriptDetachedChildrenOwner(current); + if (owner != nil) { + return owner; + } + owner = NativeScriptHostedViewOwner(current); + if (owner != nil) { + return owner; + } + current = current.superview; + depth += 1; + } + + return nil; } -- (BOOL)refreshDetachedChildrenHost { - if (_childrenView == nil) { +BOOL NativeScriptRefreshUIKitHostView(NSString* viewHandle) { + if (![NSThread isMainThread]) { return NO; } - [self layoutDetachedChildrenViewSubviewsIfNeeded]; - [self installDetachedChildrenTouchSentinelIfNeeded]; - [self attachDetachedChildrenTouchHandlerIfNeeded]; - [self updateDetachedChildrenTouchHandlerOrigin]; - [self notifyHostReadyIfNeeded]; - - return NativeScriptChildrenViewHasVisibleChild(_childrenView, _detachedTouchSentinel); -} + UIView* view = NativeScriptUIViewFromHandle(viewHandle); + if (view == nil) { + return NO; + } -- (void)installDetachedChildrenTouchSentinelIfNeeded { - if (_childrenView == nil || _detachedTouchSentinel != nil) { - return; + NativeScriptUIView* owner = NativeScriptUIKitHostOwnerForView(view); + if (owner != nil) { + return NativeScriptRefreshOwner(owner); } - NativeScriptDetachedChildrenTouchSentinel* sentinel = - [[NativeScriptDetachedChildrenTouchSentinel alloc] initWithFrame:CGRectZero]; - sentinel.owner = self; - sentinel.hidden = YES; - sentinel.userInteractionEnabled = NO; - sentinel.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; - _detachedTouchSentinel = sentinel; - [_childrenView addSubview:sentinel]; + return NativeScriptRefreshUIKitHostSubviews(view, 0); } -- (void)attachDetachedChildrenTouchHandlerIfNeeded { - if (_childrenView == nil) { - return; +BOOL NativeScriptRefreshUIKitHostViewOwner(NSString* viewHandle) { + if (![NSThread isMainThread]) { + return NO; } - UIView* touchView = _childrenView; - touchView.userInteractionEnabled = YES; - if (NativeScriptFindAncestorSurfaceTouchHandler(touchView) != nil) { - [self detachDetachedChildrenTouchHandler]; - return; + UIView* view = NativeScriptUIViewFromHandle(viewHandle); + if (view == nil) { + return NO; } - if (_detachedTouchHandler != nil) { - UIView* attachedTouchHandlerView = - NativeScriptGestureRecognizerAttachedView(_detachedTouchHandler); - if (_detachedTouchHandlerView != touchView || - (attachedTouchHandlerView != nil && attachedTouchHandlerView != touchView) || - _detachedTouchHandlerWindow != touchView.window || - !NativeScriptViewHasGestureRecognizer(touchView, _detachedTouchHandler)) { - [self detachDetachedChildrenTouchHandler]; - } else { - [self updateDetachedChildrenTouchHandlerOrigin]; - return; - } + return NativeScriptRefreshUIKitHostOwnersInAncestorChain(view); +} + +BOOL NativeScriptRefreshUIKitHostViewDirectOwner(NSString* viewHandle) { + if (![NSThread isMainThread]) { + return NO; } - if (_detachedTouchHandler != nil) { - [self updateDetachedChildrenTouchHandlerOrigin]; - return; + UIView* view = NativeScriptUIViewFromHandle(viewHandle); + if (view == nil) { + return NO; } -#if __has_include() - RCTSurfaceTouchHandler* surfaceTouchHandler = [RCTSurfaceTouchHandler new]; - [surfaceTouchHandler attachToView:touchView]; - _detachedTouchHandler = surfaceTouchHandler; - _detachedTouchHandlerView = [touchView retain]; - _detachedTouchHandlerWindow = touchView.window; - [self updateDetachedChildrenTouchHandlerOrigin]; - return; -#endif + return NativeScriptRefreshOwner(NativeScriptUIKitHostOwnerForView(view)); } -- (void)updateDetachedChildrenTouchHandlerOrigin { -#if __has_include() - if (_detachedTouchHandler == nil || _detachedTouchHandlerView == nil || - ![_detachedTouchHandler isKindOfClass:RCTSurfaceTouchHandler.class]) { - return; +BOOL NativeScriptInvalidateUIKitHostReadyOwner(NSString* viewHandle) { + if (![NSThread isMainThread]) { + return NO; } - CGPoint origin = CGPointZero; - if (_detachedTouchHandlerView.window != nil) { - origin = [_detachedTouchHandlerView convertPoint:CGPointZero - toView:_detachedTouchHandlerView.window]; + UIView* view = NativeScriptUIViewFromHandle(viewHandle); + if (view == nil) { + return NO; } - ((RCTSurfaceTouchHandler*)_detachedTouchHandler).viewOriginOffset = origin; -#endif + return NativeScriptInvalidateHostReadyOwner(NativeScriptDetachedChildrenOwner(view)) || + NativeScriptInvalidateHostReadyOwner(NativeScriptHostedViewOwner(view)); } -- (void)detachDetachedChildrenTouchHandler { - if (_detachedTouchHandler == nil || _detachedTouchHandlerView == nil) { - [_detachedTouchHandler release]; - _detachedTouchHandler = nil; - [_detachedTouchHandlerView release]; - _detachedTouchHandlerView = nil; - _detachedTouchHandlerWindow = nil; - return; +BOOL NativeScriptNotifyUIKitAccessibilityLayoutChanged(NSString* viewHandle) { + if (![NSThread isMainThread]) { + return NO; } - UIView* attachedTouchHandlerView = - NativeScriptGestureRecognizerAttachedView(_detachedTouchHandler); - UIView* detachView = - attachedTouchHandlerView != nil ? attachedTouchHandlerView : _detachedTouchHandlerView; - - if ([_detachedTouchHandler respondsToSelector:@selector(detachFromView:)]) { - if (NativeScriptViewHasGestureRecognizer(detachView, _detachedTouchHandler)) { - [_detachedTouchHandler detachFromView:detachView]; - } + UIView* view = NativeScriptUIViewFromHandle(viewHandle); + if (view == nil) { + return NO; } - [_detachedTouchHandler release]; - _detachedTouchHandler = nil; - [_detachedTouchHandlerView release]; - _detachedTouchHandlerView = nil; - _detachedTouchHandlerWindow = nil; + UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, view); + return YES; } -- (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent*)event { - [self refreshDetachedChildrenHost]; +BOOL NativeScriptFlushUIKitHostView(NSString* viewHandle) { + if (![NSThread isMainThread]) { + return NO; + } - UIView* hitView = [super hitTest:point withEvent:event]; - if (hitView == nil && _childrenView != nil && _childrenView.window != nil) { - CGPoint childrenPoint = [_childrenView convertPoint:point fromView:self]; - hitView = [_childrenView hitTest:childrenPoint withEvent:event]; + UIView* view = NativeScriptUIViewFromHandle(viewHandle); + if (view == nil) { + return NO; } - if (hitView == nil || self.window == nil) { - return hitView; + NativeScriptUIView* owner = NativeScriptUIKitHostOwnerForView(view); + BOOL flushed = NativeScriptFlushOwnerDisplay(owner); + flushed = NativeScriptFlushUIKitHostSubviews(view, 0) || flushed; + if (flushed) { + [CATransaction flush]; } + return flushed; +} - CGPoint windowPoint = [self convertPoint:point toView:self.window]; - UITabBar* tabBar = NativeScriptVisibleTabBarAtPoint(self.window, self.window, windowPoint); - if (tabBar != nil) { - if (NativeScriptViewIsDescendantOfView(tabBar, self)) { - CGPoint tabBarPoint = [tabBar convertPoint:windowPoint fromView:self.window]; - UIView* tabBarHitView = [tabBar hitTest:tabBarPoint withEvent:event]; - if (tabBarHitView != nil) { - return tabBarHitView; - } - return tabBar; - } - if (!NativeScriptViewIsDescendantOfView(self, tabBar)) { - return nil; - } +BOOL NativeScriptFlushUIKitHostViewOwner(NSString* viewHandle) { + if (![NSThread isMainThread]) { + return NO; } - return hitView; -} + UIView* view = NativeScriptUIViewFromHandle(viewHandle); + if (view == nil) { + return NO; + } -- (void)didMoveToWindow { - [super didMoveToWindow]; - [self mountUIKitHostIfNeeded]; - [self attachViewControllerIfPossible]; - [self attachDetachedChildrenTouchHandlerIfNeeded]; - [self updateDetachedChildrenTouchHandlerOrigin]; - [self notifyHostReadyIfNeeded]; + const BOOL flushed = NativeScriptFlushUIKitHostOwnersInAncestorChain(view); + if (flushed) { + [CATransaction flush]; + } + return flushed; } -- (void)layoutSubviews { - [super layoutSubviews]; - _nativeView.frame = self.bounds; - [self layoutDetachedChildrenViewSubviewsIfNeeded]; - [self installDetachedChildrenTouchSentinelIfNeeded]; - [self attachDetachedChildrenTouchHandlerIfNeeded]; - [self updateDetachedChildrenTouchHandlerOrigin]; - [self notifyHostReadyIfNeeded]; +NSDictionary* NativeScriptUIKitHostHandlesForView(NSString* viewHandle) { + if (![NSThread isMainThread]) { + return @{}; + } + + UIView* view = NativeScriptUIViewFromHandle(viewHandle); + NativeScriptUIView* owner = NativeScriptUIKitHostOwnerForView(view); + return owner == nil ? @{} : [owner uikitHostHandles]; } -@end +NSDictionary* NativeScriptUIKitHostOwnerHandlesForView(NSString* viewHandle) { + if (![NSThread isMainThread]) { + return @{}; + } -static BOOL NativeScriptRefreshUIKitHostSubviews(UIView* root, NSUInteger depth) { - if (root == nil || depth > 24) { - return NO; + UIView* view = NativeScriptUIViewFromHandle(viewHandle); + NativeScriptUIView* owner = NativeScriptUIKitHostOwnerForView(view); + if (owner == nil) { + return @{}; } - BOOL refreshed = NO; - if ([root isKindOfClass:NativeScriptUIView.class]) { - refreshed = [static_cast(root) refreshDetachedChildrenHost] || refreshed; + UIView* current = owner.superview; + NSUInteger depth = 0; + while (current != nil && depth < 24) { + NativeScriptUIView* parentOwner = NativeScriptUIKitHostOwnerForView(current); + if (parentOwner != nil && parentOwner != owner) { + return [parentOwner uikitHostHandles]; + } + current = current.superview; + depth += 1; + } + + return @{}; +} + +NSArray* NativeScriptCollectedUIKitHostChildren(NSString* viewHandle) { + if (![NSThread isMainThread]) { + return @[]; } - NativeScriptUIView* detachedChildrenOwner = NativeScriptDetachedChildrenOwner(root); - if (detachedChildrenOwner != nil) { - refreshed = [detachedChildrenOwner refreshDetachedChildrenHost] || refreshed; + UIView* view = NativeScriptUIViewFromHandle(viewHandle); + NativeScriptUIView* owner = NativeScriptUIKitHostOwnerForView(view); + return owner == nil ? @[] : [owner collectedChildComponentViews]; +} + +NSString* NativeScriptNearestViewControllerForView(NSString* viewHandle) { + if (![NSThread isMainThread]) { + return nil; } - if ([root isKindOfClass:NativeScriptDetachedChildrenTouchSentinel.class]) { - NativeScriptDetachedChildrenTouchSentinel* sentinel = - static_cast(root); - refreshed = [sentinel.owner refreshDetachedChildrenHost] || refreshed; + UIView* view = NativeScriptUIViewFromHandle(viewHandle); + if (view == nil) { + return nil; } - for (UIView* subview in root.subviews) { - refreshed = NativeScriptRefreshUIKitHostSubviews(subview, depth + 1) || refreshed; + UIViewController* controller = NativeScriptClosestReactViewControllerForView(view, nil); + if (controller == nil) { + controller = NativeScriptNearestViewController(view, nil); } - return refreshed; + return NativeScriptHandleFromNSObject(controller); } -BOOL NativeScriptRefreshUIKitHostView(NSString* viewHandle) { +BOOL NativeScriptAttachViewControllerToNearestParent(NSString* controllerHandle, + NSString* viewHandle, + BOOL allowRootParent) { if (![NSThread isMainThread]) { return NO; } + UIViewController* controller = NativeScriptUIViewControllerFromHandle(controllerHandle); UIView* view = NativeScriptUIViewFromHandle(viewHandle); - if (view == nil) { + if (controller == nil || view == nil || view.window == nil) { return NO; } - return NativeScriptRefreshUIKitHostSubviews(view, 0); + UIViewController* parent = NativeScriptClosestReactViewControllerForView(view, controller); + if (parent == nil) { + parent = NativeScriptNearestResponderViewController(view, controller); + } + if (parent == nil || parent == controller) { + return NO; + } + if (!allowRootParent && parent == view.window.rootViewController) { + return NO; + } + + if (controller.parentViewController == parent) { + return NO; + } + + if (controller.parentViewController != nil) { + [controller willMoveToParentViewController:nil]; + [controller removeFromParentViewController]; + } + + [parent addChildViewController:controller]; + [controller didMoveToParentViewController:parent]; + return YES; } diff --git a/packages/react-native/ios/NativeScriptUIViewManager.mm b/packages/react-native/ios/NativeScriptUIViewManager.mm index 9de511f65..d86b1f58e 100644 --- a/packages/react-native/ios/NativeScriptUIViewManager.mm +++ b/packages/react-native/ios/NativeScriptUIViewManager.mm @@ -16,8 +16,27 @@ - (UIView*)view { RCT_EXPORT_VIEW_PROPERTY(nativeViewHandle, NSString) RCT_EXPORT_VIEW_PROPERTY(childrenViewHandle, NSString) RCT_EXPORT_VIEW_PROPERTY(controllerHandle, NSString) +RCT_EXPORT_VIEW_PROPERTY(attachNativeView, BOOL) +RCT_EXPORT_VIEW_PROPERTY(attachControllerToParent, BOOL) +RCT_EXPORT_VIEW_PROPERTY(collectChildren, BOOL) +RCT_EXPORT_VIEW_PROPERTY(detachControllerFromParent, BOOL) RCT_EXPORT_VIEW_PROPERTY(detachControllerView, BOOL) +RCT_EXPORT_VIEW_PROPERTY(disableDetachedChildrenTouchHandler, BOOL) +RCT_EXPORT_VIEW_PROPERTY(disableUIKitHostWindowAttachRefresh, BOOL) +RCT_EXPORT_VIEW_PROPERTY(emitOffWindowHostReady, BOOL) +RCT_EXPORT_VIEW_PROPERTY(ignoreHostReadyWindowAttachment, BOOL) +RCT_EXPORT_VIEW_PROPERTY(externalDetachedChildrenOwner, BOOL) +RCT_EXPORT_VIEW_PROPERTY(fabricLifecycleCallbacks, BOOL) +RCT_EXPORT_VIEW_PROPERTY(immediateTransactionCommit, BOOL) +RCT_EXPORT_VIEW_PROPERTY(mountChildrenDirectlyToChildrenView, BOOL) +RCT_EXPORT_VIEW_PROPERTY(layoutDirectChildrenToChildrenViewBounds, BOOL) +RCT_EXPORT_VIEW_PROPERTY(pinNativeViewToHost, BOOL) +RCT_EXPORT_VIEW_PROPERTY(preserveDetachedChildrenLayout, BOOL) +RCT_EXPORT_VIEW_PROPERTY(detachedChildrenContentOffsetX, CGFloat) +RCT_EXPORT_VIEW_PROPERTY(detachedChildrenContentOffsetY, CGFloat) RCT_EXPORT_VIEW_PROPERTY(debugName, NSString) +RCT_EXPORT_VIEW_PROPERTY(uikitHostPropsJson, NSString) +RCT_EXPORT_VIEW_PROPERTY(uikitHostPropsRevision, NSInteger) RCT_EXPORT_VIEW_PROPERTY(hostId, NSString) RCT_EXPORT_VIEW_PROPERTY(hostReadyId, NSString) RCT_EXPORT_VIEW_PROPERTY(updateRevision, NSInteger) diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 0913beac7..55aac30d4 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -21,7 +21,7 @@ "license": "Apache-2.0", "main": "src/index.ts", "react-native": "src/index.ts", - "types": "src/index.d.ts", + "types": "src/index.ts", "bin": { "nativescript-rn": "cli/configure.js", "nativescript-rn-generate-metadata": "cli/generate-metadata.js" @@ -67,7 +67,8 @@ "NativeScriptUIView": "NativeScriptUIViewComponentView" }, "modulesProvider": { - "NativeScriptNativeApi": "NativeScriptNativeApiModuleProvider" + "NativeScriptNativeApi": "NativeScriptNativeApiModuleProvider", + "WorkletsModule": "WorkletsModule" } } } diff --git a/packages/react-native/src/NativeScriptUIViewNativeComponent.ts b/packages/react-native/src/NativeScriptUIViewNativeComponent.ts index e6b930c63..4ec24fbf7 100644 --- a/packages/react-native/src/NativeScriptUIViewNativeComponent.ts +++ b/packages/react-native/src/NativeScriptUIViewNativeComponent.ts @@ -1,5 +1,6 @@ import type {HostComponent, ViewProps} from 'react-native'; import type { + Double, DirectEventHandler, Int32, } from 'react-native/Libraries/Types/CodegenTypes'; @@ -8,10 +9,13 @@ import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNati export type HostReadyEvent = { hostReadyId: string; hostId: string; + componentViewHandle: string; nativeViewHandle: string; childrenViewHandle: string; controllerHandle: string; hasChildren: boolean; + visibleDescendantCount: Int32; + windowAttached: boolean; }; export interface NativeProps extends ViewProps { @@ -20,8 +24,29 @@ export interface NativeProps extends ViewProps { nativeViewHandle?: string; childrenViewHandle?: string; controllerHandle?: string; + attachNativeView?: boolean; + attachControllerToParent?: boolean; + adoptHostViewAsControllerView?: boolean; + collectChildren?: boolean; + detachControllerFromParent?: boolean; detachControllerView?: boolean; + disableDetachedChildrenTouchHandler?: boolean; + disableUIKitHostWindowAttachRefresh?: boolean; + emitOffWindowHostReady?: boolean; + ignoreHostReadyWindowAttachment?: boolean; + externalDetachedChildrenOwner?: boolean; + fabricLifecycleCallbacks?: boolean; + immediateTransactionCommit?: boolean; + deferTransactionCommitOnRemovals?: boolean; + mountChildrenDirectlyToChildrenView?: boolean; + layoutDirectChildrenToChildrenViewBounds?: boolean; + pinNativeViewToHost?: boolean; + preserveDetachedChildrenLayout?: boolean; + detachedChildrenContentOffsetX?: Double; + detachedChildrenContentOffsetY?: Double; debugName?: string; + uikitHostPropsJson?: string; + uikitHostPropsRevision?: Int32; updateRevision?: Int32; mountedRevision?: Int32; onHostReady?: DirectEventHandler; diff --git a/packages/react-native/src/index.d.ts b/packages/react-native/src/index.d.ts deleted file mode 100644 index 3e15e684d..000000000 --- a/packages/react-native/src/index.d.ts +++ /dev/null @@ -1,373 +0,0 @@ -/// - -import type { - ForwardRefExoticComponent, - PropsWithoutRef, - RefAttributes, -} from "react"; -import type { ViewProps } from "react-native"; - -export type NativeApiHost = { - runtime?: string; - backend?: string; - metadata?: { - classes?: number; - functions?: number; - constants?: number; - protocols?: number; - enums?: number; - structs?: number; - unions?: number; - classNames?: () => string[]; - functionNames?: () => string[]; - constantNames?: () => string[]; - protocolNames?: () => string[]; - enumNames?: () => string[]; - structNames?: () => string[]; - unionNames?: () => string[]; - }; - import?: (path: string) => boolean; - getClass?: (name: string) => unknown; - getProtocol?: (name: string) => unknown; - getEnum?: (name: string) => unknown; - getStruct?: (name: string) => unknown; - getUnion?: (name: string) => unknown; - [name: string]: unknown; -}; - -export type InstallOptions = { - /** - * Install Objective-C classes/functions/constants as RN runtime globals. - * Native UI should run through worklets; React Native defaults this off so - * UIKit cannot be touched from the RN JavaScript thread by accident. - */ - globals?: boolean; -}; - -export type NativeScriptWorklets = { - getUIRuntimeHolder: () => object; - isWorkletFunction: (value: unknown) => boolean; - runOnUIAsync: ( - callback: (...args: Args) => ReturnValue | Promise, - ...args: Args - ) => Promise; -}; - -export type UIKitSizingMode = - | "fill" - | "intrinsic" - | "sizeThatFits" - | "autoLayout"; - -export type UIKitLayoutOptions = { - sizing?: UIKitSizingMode; - defaultSize?: { width?: number; height?: number }; - minSize?: { width?: number; height?: number }; - maxSize?: { width?: number; height?: number }; -}; - -export type UIKitHostReadyEvent = { - nativeEvent: { - hostReadyId: string; - hostId: string; - nativeViewHandle: string; - childrenViewHandle: string; - controllerHandle: string; - hasChildren: boolean; - }; -}; - -export type UIKitViewContext = { - readonly name: string; - readonly tag: number | null; - readonly props: Readonly; - emit( - eventName: K, - payload?: Props[K] extends ((arg: infer Payload) => unknown) | undefined - ? Payload - : unknown, - ): void; - targetAction(control: unknown, events: unknown, callback: () => void): void; - gestureAction(gesture: unknown, callback: (gesture: unknown) => void): void; - actionTarget(callback: (sender: unknown) => void): { - target: unknown; - action: string; - }; - delegate( - object: unknown, - protocolRef: unknown, - implementation: Partial, - ): T; - notification( - name: string, - object: unknown | null, - callback: (notification: unknown) => void, - ): void; - observe( - object: unknown, - keyPath: string, - callback: (value: unknown, change: unknown) => void, - ): void; - retain(value: T): T; - release(value?: unknown): void; - dispose(callback: () => void): void; - invalidateLayout(): void; - loadImage( - source: unknown, - options: NativeScriptImageLoadOptions, - callback: NativeScriptImageLoadCallback, - ): boolean; -}; - -export type NativeScriptImageLoadOptions = { - template?: boolean; -}; -export type NativeScriptImageLoadCallback = ( - image: unknown | null, - error: Error | null, -) => void; -export type NativeScriptCallbackThread = "js" | "runtime"; -export type NativeScriptInvokedCallback any> = - T & { - readonly __nativeScriptCallbackThread?: NativeScriptCallbackThread; - readonly __nativeScriptWrappedCallback?: T; - }; -export type NativeRetainer = { - readonly size: number; - retain(value: T): T; - release(value?: unknown): void; - dispose(): void; -}; -export type NativeDelegateOwner = { - retain(value: T): T | void; - release?(value?: unknown): void; - dispose?(callback: () => void): void; -}; -export type NativeProtocolReference = string | object | Function; -export type CreateDelegateOptions = { - name?: string; - thread?: NativeScriptCallbackThread | "caller"; - retainer?: NativeRetainer; - owner?: NativeDelegateOwner; - assignTo?: { - object: unknown; - property?: string; - }; -}; - -export type UIKitDisposeResult = - | void - | { - removeHostView?: boolean; - }; - -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; - layout?: UIKitLayoutOptions; - create: ( - ctx: UIKitViewContext & Readonly, - ) => NativeView; - update?: ( - view: NativeView, - props: Readonly, - previousProps?: Readonly, - ctx?: UIKitViewContext, - ) => void; - mounted?: ( - view: NativeView, - props: Readonly, - ctx?: UIKitViewContext, - ) => void; - dispose?: ( - view: NativeView, - props: Readonly, - ctx?: UIKitViewContext, - ) => UIKitDisposeResult; - nativeProps?: ( - props: Readonly, - ) => Partial | undefined; -}; - -export type UIKitViewRef = { - readonly nativeView: NativeView | null; - runOnUI: (callback: (view: NativeView) => T) => Promise; - measureNative: () => Promise<{ width: number; height: number }>; - invalidateNativeLayout: () => void; -}; - -export type UIKitHostViewProps = ViewProps & { - attachController?: boolean; - attachControllerView?: boolean; - attachNativeView?: boolean; - onHostReady?: (event: UIKitHostReadyEvent) => void; -}; - -export type UIKitViewComponent< - Props extends object, - NativeView = unknown, -> = ForwardRefExoticComponent< - PropsWithoutRef & - RefAttributes> ->; - -export type UIKitContainerResult = { - rootView: RootView; - childrenView: ChildrenView; -}; - -export type UIKitContainerDefinition< - Props extends object, - RootView = unknown, - ChildrenView = unknown, -> = Omit< - UIKitViewDefinition>, - "create" | "update" | "mounted" | "dispose" -> & { - create: ( - ctx: UIKitViewContext & Readonly, - ) => UIKitContainerResult; - update?: ( - view: UIKitContainerResult, - props: Readonly, - previousProps?: Readonly, - ctx?: UIKitViewContext, - ) => void; - mounted?: ( - view: UIKitContainerResult, - props: Readonly, - ctx?: UIKitViewContext, - ) => void; - dispose?: ( - view: UIKitContainerResult, - props: Readonly, - ctx?: UIKitViewContext, - ) => UIKitDisposeResult; -}; - -export type UIViewControllerDefinition< - Props extends object, - Controller = unknown, -> = Omit, "create"> & { - createController: ( - ctx: UIKitViewContext & Readonly, - ) => Controller; - hostView?: (controller: Controller) => unknown; - childrenView?: (controller: Controller) => unknown; -}; - -export function init(metadataPath?: string, options?: InstallOptions): boolean; -export const install: typeof init; -export function installGlobals(): boolean; -export function isInstalled(): boolean; -export function defaultMetadataPath(): string; -export function getRuntimeBackend(): string; -export function installWorklets( - worklets?: NativeScriptWorklets, - metadataPath?: string, -): boolean; -export function runOnUI( - callback: (...args: Args) => ReturnValue | Promise, - ...args: Args -): Promise; -export function uiInvoker any>( - callback: T, -): never; -export function jsInvoker any>( - callback: T, -): NativeScriptInvokedCallback; -export function runtimeInvoker any>( - callback: T, -): NativeScriptInvokedCallback; -export function eventBridge any>( - callback: T, - thread?: NativeScriptCallbackThread | "caller", -): T | NativeScriptInvokedCallback; -export const createEventBridge: typeof eventBridge; -export function isMainThread(): boolean; -export function assertUIKitThread(message?: string): void; -export function refreshUIKitHostView(view: unknown): boolean; -export function refreshUIKitHostViewHandle(viewHandle: string): boolean; -export function loadImage( - source: unknown, - options: NativeScriptImageLoadOptions, - callback: NativeScriptImageLoadCallback, -): boolean; -export function warnIfNotUIKitThread(message?: string): boolean; -export function createRetainer(): NativeRetainer; -export function retain(value: T): T; -export function release(value?: unknown): void; -export function getClass(name: string): T | null; -export function getProtocol(name: string): T | null; -export function isClassAvailable(name: string): boolean; -export function isFrameworkLoaded(nameOrPath: string): boolean; -export function loadFramework(nameOrPath: string): boolean; -export function createDelegate( - protocols: NativeProtocolReference | NativeProtocolReference[], - methods: Partial, - options?: CreateDelegateOptions, -): T; -export function defineUIKitView( - definition: UIKitViewDefinition, -): UIKitViewComponent; -export function defineUIKitContainer< - Props extends object, - RootView = unknown, - ChildrenView = unknown, ->( - definition: UIKitContainerDefinition, -): UIKitViewComponent>; -export function defineUIViewController< - Props extends object, - Controller = unknown, ->( - definition: UIViewControllerDefinition, -): UIKitViewComponent; - -declare const NativeScript: { - init: typeof init; - install: typeof install; - installGlobals: typeof installGlobals; - isInstalled: typeof isInstalled; - defaultMetadataPath: typeof defaultMetadataPath; - defineUIKitContainer: typeof defineUIKitContainer; - defineUIKitView: typeof defineUIKitView; - defineUIViewController: typeof defineUIViewController; - getRuntimeBackend: typeof getRuntimeBackend; - installWorklets: typeof installWorklets; - assertUIKitThread: typeof assertUIKitThread; - createDelegate: typeof createDelegate; - createEventBridge: typeof createEventBridge; - createRetainer: typeof createRetainer; - eventBridge: typeof eventBridge; - getClass: typeof getClass; - getProtocol: typeof getProtocol; - isClassAvailable: typeof isClassAvailable; - isFrameworkLoaded: typeof isFrameworkLoaded; - isMainThread: typeof isMainThread; - jsInvoker: typeof jsInvoker; - loadFramework: typeof loadFramework; - release: typeof release; - retain: typeof retain; - refreshUIKitHostView: typeof refreshUIKitHostView; - runOnUI: typeof runOnUI; - runtimeInvoker: typeof runtimeInvoker; - uiInvoker: typeof uiInvoker; - warnIfNotUIKitThread: typeof warnIfNotUIKitThread; -}; - -export default NativeScript; diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index c7b9a2bb5..4eda03308 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -1,3 +1,18 @@ +// @ts-nocheck +/// +/** + * `@nativescript/react-native` — the TurboModule + Fabric host that lets + * TypeScript worklets own real UIKit views. This module IS the package's public + * type surface (`package.json` "types" points here); the hand-written + * `index.d.ts` it replaced had drifted out of sync with the implementation. + * + * `@ts-nocheck` keeps this babel-authored body — with its intentional loose + * casts and worklet-runtime globals — from injecting strict-mode noise into + * consumers' type checks, while every `export`ed declaration below still forms + * the checkable public contract. The triple-slash reference above pulls the + * generated iOS interop globals (UIView, NSObject, …) into scope for the + * worklet host bodies. + */ import React, { forwardRef, useEffect, @@ -11,14 +26,28 @@ import type { PropsWithoutRef, RefAttributes, } from "react"; -import type { ViewProps } from "react-native"; +import { findNodeHandle, type ViewProps } from "react-native"; import NativeScriptNativeApi from "./NativeScriptNativeApi"; import NativeScriptUIViewNativeComponent from "./NativeScriptUIViewNativeComponent"; declare const require: (id: string) => any; -type NativeApiHost = { +/** + * The Native API host object installed by the TurboModule. Reflects the loaded + * runtime/backend, metadata counts + name lists, and the class/protocol/enum/ + * struct/union lookups the interop layer builds on. + */ +export type NativeApiHost = { + runtime?: string; + backend?: string; metadata?: { + classes?: number; + functions?: number; + constants?: number; + protocols?: number; + enums?: number; + structs?: number; + unions?: number; classNames?: () => string[]; functionNames?: () => string[]; constantNames?: () => string[]; @@ -52,67 +81,194 @@ export type NativeScriptWorklets = { callback: (...args: Args) => ReturnValue | Promise, ...args: Args ) => Promise; + runOnUISync: ( + callback: (...args: Args) => ReturnValue, + ...args: Args + ) => ReturnValue; +}; + +export type ReactNativeFabricViewLayoutTraits = { + isFabricComponentView: boolean; + hasYogaStyle: boolean; + hasLayoutMetrics: boolean; + flex: number | null; + flexGrow: number | null; + flexShrink: number | null; + frameX?: number; + frameY?: number; + frameWidth?: number; + frameHeight?: number; + layoutMetricsFrameX?: number; + layoutMetricsFrameY?: number; + layoutMetricsFrameWidth?: number; + layoutMetricsFrameHeight?: number; + layoutMetricsContentFrameX?: number; + layoutMetricsContentFrameY?: number; + layoutMetricsContentFrameWidth?: number; + layoutMetricsContentFrameHeight?: number; }; +/** + * How the native host measures itself: `fill` (match the RN frame), + * `intrinsic` (`intrinsicContentSize`), `sizeThatFits` (ask the view), or + * `autoLayout` (systemLayoutSizeFitting via constraints). + */ export type UIKitSizingMode = - | "fill" - | "intrinsic" - | "sizeThatFits" - | "autoLayout"; + "fill" | "intrinsic" | "sizeThatFits" | "autoLayout"; +/** Native sizing/measurement config for a host (`definition.layout`). */ export type UIKitLayoutOptions = { + /** Measurement strategy — see {@link UIKitSizingMode} (default `fill`). */ sizing?: UIKitSizingMode; + /** Fallback size used before/without a measured size. */ defaultSize?: { width?: number; height?: number }; + /** Clamp the measured size from below. */ minSize?: { width?: number; height?: number }; + /** Clamp the measured size from above. */ maxSize?: { width?: number; height?: number }; }; +/** `onHostReady` payload: native handles + readiness for a mounted host. */ export type UIKitHostReadyEvent = { nativeEvent: { hostReadyId: string; hostId: string; + componentViewHandle: string; nativeViewHandle: string; childrenViewHandle: string; controllerHandle: string; hasChildren: boolean; + visibleDescendantCount: number; + windowAttached: boolean; }; }; +export type UIKitHostNativeHandles = { + componentViewHandle?: string; + containerViewHandle?: string; + nativeViewHandle?: string; + childrenViewHandle?: string; + controllerHandle?: string; +}; + +export type UIKitNativeMountInfo = { + fabricComponentView?: unknown | null; + fabricComponentViewHandle?: string; + fabricContainerView?: unknown | null; + fabricContainerViewHandle?: string; +}; + +export type UIKitFabricTransaction = { + readonly children: readonly UIKitFabricMountedChild[]; + readonly hasModifiedChildren: boolean; + readonly hasModifiedProps: boolean; + readonly mutations: readonly UIKitFabricMutation[]; + // SEAM D STAGE 0 follow-up: the shared per-host delivery token, bumped + // exactly-once per ACTUAL native transactionCommitted delivery. Present + // (and monotonically increasing) only on transactions parsed from a real + // native payload; synthesized/empty transactions omit it. + readonly deliveryToken?: number; +}; + +export type UIKitFabricMutation = { + readonly type: string; + readonly parentTag: number | null; + readonly index: number; + readonly newChildTag: number | null; + readonly newChildComponentName: string; + readonly oldChildTag: number | null; + readonly oldChildComponentName: string; +}; + +export type UIKitFabricMountedChild = { + readonly index: number; + readonly ownerComponentView: unknown | null; + readonly ownerComponentViewHandle: string; + readonly ownerContainerView: unknown | null; + readonly ownerContainerViewHandle: string; + readonly ownerNativeView: unknown | null; + readonly ownerNativeViewHandle: string; + readonly ownerChildrenView: unknown | null; + readonly ownerChildrenViewHandle: string; + readonly ownerController: unknown | null; + readonly ownerControllerHandle: string; + readonly componentView: unknown | null; + readonly componentViewHandle: string; + readonly containerView: unknown | null; + readonly containerViewHandle: string; + readonly nativeView: unknown | null; + readonly nativeViewHandle: string; + readonly childrenView: unknown | null; + readonly childrenViewHandle: string; + readonly controller: unknown | null; + readonly controllerHandle: string; +}; + +/** + * Per-host context passed to every lifecycle callback. Runs on the UI runtime; + * its helpers register native callbacks/observations that are auto-torn-down + * when the host disposes. + */ export type UIKitViewContext = { + /** Host debug name (the definition's `name`). */ readonly name: string; + /** RN reactTag of the host component, or `null` before it mounts. */ readonly tag: number | null; + /** Current props snapshot (RN + your own). */ readonly props: Readonly; + /** The Fabric component view + its native handle. */ + readonly fabricComponentView: unknown | null; + readonly fabricComponentViewHandle: string; + /** The Fabric container view + its native handle. */ + readonly fabricContainerView: unknown | null; + readonly fabricContainerViewHandle: string; + /** The active Fabric mount transaction (mutations + delivery token). */ + readonly fabricTransaction: UIKitFabricTransaction; + /** Asynchronously invoke the matching React callback prop by name. */ emit( eventName: K, payload?: Props[K] extends ((arg: infer Payload) => unknown) | undefined ? Payload : unknown, ): void; + /** Add a retained control target/action, auto-removed on dispose. */ targetAction(control: unknown, events: unknown, callback: () => void): void; + /** Add a retained gesture-recognizer target/action, auto-removed on dispose. */ gestureAction(gesture: unknown, callback: (gesture: unknown) => void): void; + /** Create a standalone retained target/action pair (exposes its callback key). */ actionTarget(callback: (sender: unknown) => void): { - target: unknown; action: string; + callbackKey: string; + invoke(sender?: unknown): boolean; + target: unknown; }; + /** Create, assign, and retain a protocol delegate on `object`. */ delegate( object: unknown, protocolRef: unknown, implementation: Partial, ): T; + /** Observe an `NSNotification`, auto-removed on dispose. */ notification( name: string, object: unknown | null, callback: (notification: unknown) => void, ): void; + /** Add a KVO observation for `keyPath`, auto-removed on dispose. */ observe( object: unknown, keyPath: string, callback: (value: unknown, change: unknown) => void, ): void; + /** Keep a native helper alive for the component lifetime. */ retain(value: T): T; + /** Release a retained helper before disposal. */ release(value?: unknown): void; + /** Register cleanup; runs once, in reverse registration order. */ dispose(callback: () => void): void; + /** Schedule a fresh native measurement pass. */ invalidateLayout(): void; + /** Resolve an RN image source to a native `UIImage`. */ loadImage( source: unknown, options: NativeScriptImageLoadOptions, @@ -132,50 +288,168 @@ export type NativeScriptImageLoadCallback = ( error: Error | null, ) => void; -export type UIKitDisposeResult = - | void - | { - removeHostView?: boolean; - }; +export type UIKitDisposeResult = void | { + removeHostView?: boolean; +}; +/** + * A native host definition: what to build, how to keep it in sync, and how to + * tear it down. Every callback runs on the UI runtime (no `runOnUI()` wrapping + * needed inside them). + */ export type UIKitViewDefinition = { + /** Registered host name; also the native view's debug name. */ name?: string; + /** Debug name override (falls back to `name`). */ debugName?: string; + /** React `displayName` for the generated component. */ displayName?: string; + /** Native sizing/measurement strategy — see {@link UIKitLayoutOptions}. */ layout?: UIKitLayoutOptions; + /** Opt into native mount-info round-trips for Fabric-style hosting. */ + requiresNativeMountInfo?: boolean; + /** Build the native view. `ctx` is spread onto the argument, so `create(props)` still works. */ create: (ctx: UIKitCreateArgument) => NativeView; + /** Apply prop changes to the native view. */ update?: ( view: NativeView, props: Readonly, previousProps?: Readonly, ctx?: UIKitViewContext, ) => void; + /** Opt-in re-sync when UIKit moved the host without a React prop change. */ + refresh?: ( + view: NativeView, + props: Readonly, + previousProps?: Readonly, + ctx?: UIKitViewContext, + ) => void; + /** Run after a Fabric transaction commits into the host. */ + transactionCommitted?: ( + view: NativeView, + props: Readonly, + previousProps?: Readonly, + ctx?: UIKitViewContext, + ) => void; + /** Fabric mounting-transaction boundaries (requires `fabricLifecycleCallbacks`). */ + mountingTransactionWillMount?: ( + view: NativeView, + props: Readonly, + previousProps?: Readonly, + ctx?: UIKitViewContext, + ) => void; + mountingTransactionDidMount?: ( + view: NativeView, + props: Readonly, + previousProps?: Readonly, + ctx?: UIKitViewContext, + ) => void; + /** Called as individual Fabric children mount/unmount into the host. */ + mountChild?: ( + view: NativeView, + child: UIKitFabricMountedChild, + props: Readonly, + previousProps?: Readonly, + ctx?: UIKitViewContext, + ) => void; + unmountChild?: ( + view: NativeView, + child: UIKitFabricMountedChild, + props: Readonly, + previousProps?: Readonly, + ctx?: UIKitViewContext, + ) => void; + /** Fires once the native host is attached and ready (window/attachment gated). */ + hostReady?: ( + view: NativeView, + props: Readonly, + event: UIKitHostReadyEvent, + previousProps?: Readonly, + ctx?: UIKitViewContext, + ) => void; + /** Runs once after the first successful mount. */ mounted?: ( view: NativeView, props: Readonly, ctx?: UIKitViewContext, ) => void; + /** Teardown. Return `{ removeHostView: true }` to also drop the RN host view. */ dispose?: ( view: NativeView, props: Readonly, ctx?: UIKitViewContext, ) => UIKitDisposeResult; - nativeProps?: ( - props: Readonly, - ) => Partial | undefined; + /** Static or derived RN props to also apply to the host view (e.g. layout style). */ + nativeProps?: + | Partial + | ((props: Readonly) => Partial | undefined); }; +/** Imperative handle returned via `ref` to a native host component. */ export type UIKitViewRef = { + /** The native view, or `null` before mount / after dispose. */ readonly nativeView: NativeView | null; + /** Run a `"worklet"` against the native view on the UI runtime. */ runOnUI: (callback: (view: NativeView) => T) => Promise; + /** Measure the native view (resolves its current size). */ measureNative: () => Promise<{ width: number; height: number }>; + /** Schedule a fresh native measurement pass. */ invalidateNativeLayout: () => void; }; +/** + * Hosting-strategy flags carried on the shared host component in addition to the + * standard RN {@link ViewProps}. Most apps need none of these; adapters set them + * to pick a UIKit containment model. Each is one-lined in the package README. + */ export type UIKitHostViewProps = ViewProps & { + /** + * Upstream react-native-screens hosting shape: the Fabric-managed host + * view becomes the controller's view (controller.view = host container), + * so UIKit containment moves mounted React children wholesale. Mutually + * exclusive with attachNativeView/attachControllerView-style containment. + */ + adoptHostViewAsControllerView?: boolean; + /** Add the hosted controller as a child view controller. */ attachController?: boolean; + /** Attach the hosted controller to the nearest parent controller. */ + attachControllerToParent?: boolean; + /** Insert the controller's view into the host. */ attachControllerView?: boolean; + /** Insert the native view into the host. */ attachNativeView?: boolean; + /** Expose mounted Fabric children for collection instead of mounting them. */ + collectChildren?: boolean; + /** Remove the hosted controller from its parent controller. */ + detachControllerFromParent?: boolean; + /** Skip the generic window-attach refresh when native containment owns the hot path. */ + disableUIKitHostWindowAttachRefresh?: boolean; + /** Emit `hostReady` even while the host is off-window. */ + emitOffWindowHostReady?: boolean; + /** Do not gate `hostReady` on window attachment. */ + ignoreHostReadyWindowAttachment?: boolean; + /** Enable the Fabric mounting-transaction lifecycle hooks. */ + fabricLifecycleCallbacks?: boolean; + /** Commit Fabric transactions to the host immediately. */ + immediateTransactionCommit?: boolean; + /** Defer transaction commits when the mutation only removes children. */ + deferTransactionCommitOnRemovals?: boolean; + /** Mount RN children straight into the children view. */ + mountChildrenDirectlyToChildrenView?: boolean; + /** Lay out direct children to the children view's bounds. */ + layoutDirectChildrenToChildrenViewBounds?: boolean; + /** Pin the native view to the host bounds. */ + pinNativeViewToHost?: boolean; + /** Opt out of the detached-children touch handler (an upstream surface owns touches). */ + disableDetachedChildrenTouchHandler?: boolean; + /** This host's children are owned by an external detached-children owner. */ + externalDetachedChildrenOwner?: boolean; + /** Preserve Fabric child layout instead of re-laying it out on refresh. */ + preserveDetachedChildrenLayout?: boolean; + /** X/Y offset applied to detached hosted content. */ + detachedChildrenContentOffsetX?: number; + detachedChildrenContentOffsetY?: number; + /** React callback for the `hostReady` lifecycle event. */ onHostReady?: (event: UIKitHostReadyEvent) => void; }; @@ -198,7 +472,7 @@ export type UIKitContainerDefinition< ChildrenView = unknown, > = Omit< UIKitViewDefinition>, - "create" | "update" | "mounted" | "dispose" + "create" | "update" | "refresh" | "mounted" | "dispose" > & { create: ( ctx: UIKitCreateArgument, @@ -209,6 +483,18 @@ export type UIKitContainerDefinition< previousProps?: Readonly, ctx?: UIKitViewContext, ) => void; + refresh?: ( + view: UIKitContainerResult, + props: Readonly, + previousProps?: Readonly, + ctx?: UIKitViewContext, + ) => void; + transactionCommitted?: ( + view: UIKitContainerResult, + props: Readonly, + previousProps?: Readonly, + ctx?: UIKitViewContext, + ) => void; mounted?: ( view: UIKitContainerResult, props: Readonly, @@ -232,17 +518,29 @@ export type UIViewControllerDefinition< const nativeApiGlobalName = "__nativeScriptNativeApi"; const nativeApiGlobalCacheName = "__nativeScriptNativeApiGlobalCache"; +const nativeApiClassWrapperCacheName = "__nativeScriptNativeApiClassWrappers"; const nativeApiTypeCodeKey = "__nativeApiTypeCode"; const nativeApiCallbackThreadKey = "__nativeScriptCallbackThread"; +const nativeApiMethodPolicyKey = "__nativeScriptMethodPolicy"; const nativeApiWrappedCallbackKey = "__nativeScriptWrappedCallback"; -const nativeClassWrappers = new WeakMap(); - export type NativeScriptCallbackThread = "js" | "runtime"; type AnyFunction = (...args: any[]) => any; +// Deliberately small: the only live consumers are calling the ObjC super +// implementation before the JS override runs (callSuperBeforeCallback), and +// suppressing a re-entrant callback while the receiver's associated-object +// state says one is already in flight (skipCallbackIfAssociatedObjectTruthy). +export type NativeScriptMethodCallbackPolicy = { + callSuper?: "before"; + callSuperBeforeCallback?: boolean; + skipCallbackIfAssociatedObjectTruthy?: string | string[]; +}; export type NativeScriptInvokedCallback = T & { readonly __nativeScriptCallbackThread?: NativeScriptCallbackThread; readonly __nativeScriptWrappedCallback?: T; }; +export type NativeScriptMethodPolicyCallback = T & { + readonly __nativeScriptMethodPolicy?: NativeScriptMethodCallbackPolicy; +}; const nativeCallbackMetadataSkipKeys = new Set([ "length", @@ -252,6 +550,27 @@ const nativeCallbackMetadataSkipKeys = new Set([ "caller", ]); +function jsString(value: unknown): string { + try { + return `${value}`; + } catch { + return ""; + } +} + +function jsError(message: string): Error { + try { + console.error(`[NativeScript] ${message}`); + } catch { + // Ignore broken console implementations while preserving the thrown value. + } + return { + name: "Error", + message, + stack: message, + } as Error; +} + export type NativeRetainer = { readonly size: number; retain(value: T): T; @@ -278,13 +597,26 @@ export type CreateDelegateOptions = { export type NativeProtocolReference = string | object | Function; +export type NativeAssociationPolicy = + | "assign" + | "retain" + | "retainNonatomic" + | "strong" + | "strongNonatomic" + | "copy" + | "copyNonatomic" + | number; + function nativeApiHost(): NativeApiHost | undefined { + "worklet"; + return (globalThis as Record)[nativeApiGlobalName] as - | NativeApiHost - | undefined; + NativeApiHost | undefined; } function requireNativeApiHost(): NativeApiHost { + "worklet"; + const api = nativeApiHost(); if (!api) { throw new Error( @@ -294,6 +626,85 @@ function requireNativeApiHost(): NativeApiHost { return api; } +function nativeApiValue(name: string): unknown { + "worklet"; + + if (!name) { + return undefined; + } + const api = nativeApiHost() as Record | undefined; + return api?.[name]; +} + +function nativeApiClass(name: string): any | null { + "worklet"; + + if (!name) { + return null; + } + const api = nativeApiHost(); + if (!api) { + return null; + } + const nativeClass = api.getClass?.(name) ?? api[name]; + return nativeClass == null ? null : nativeClass; +} + +function nativeApiEnum(name: string): unknown { + "worklet"; + + if (!name) { + return undefined; + } + const api = nativeApiHost(); + if (!api) { + return undefined; + } + return api.getEnum?.(name) ?? api[name]; +} + +function nativeApiClassWrapperCache(): { + get(key: object): unknown; + set(key: object, value: unknown): unknown; +} { + "worklet"; + + const globalObject = globalThis as Record; + const existing = globalObject[nativeApiClassWrapperCacheName] as + | { + get?: (key: object) => unknown; + set?: (key: object, value: unknown) => unknown; + } + | undefined; + if ( + existing && + typeof existing.get === "function" && + typeof existing.set === "function" + ) { + return existing as { + get(key: object): unknown; + set(key: object, value: unknown): unknown; + }; + } + + let cache: { + get(key: object): unknown; + set(key: object, value: unknown): unknown; + }; + try { + cache = new WeakMap(); + } catch { + cache = new Map(); + } + Object.defineProperty(globalThis, nativeApiClassWrapperCacheName, { + configurable: false, + enumerable: false, + writable: false, + value: cache, + }); + return cache; +} + function nativeApiGlobalCache(): Record { const globalObject = globalThis as Record; const existing = globalObject[nativeApiGlobalCacheName]; @@ -318,7 +729,11 @@ function cacheNativeGlobal(name: string, value: unknown): void { nativeApiGlobalCache()[name] = value; } +const defaultNativeRetainerGlobalName = "__nativeScriptDefaultNativeRetainer"; + function createNativeRetainer(): NativeRetainer { + "worklet"; + const retained: unknown[] = []; return { get size() { @@ -345,22 +760,32 @@ function createNativeRetainer(): NativeRetainer { }; } -const defaultNativeRetainer = createNativeRetainer(); - -export function createRetainer(): NativeRetainer { - return createNativeRetainer(); -} +function defaultNativeRetainerForRuntime(): NativeRetainer { + "worklet"; -export function retain(value: T): T { - return defaultNativeRetainer.retain(value); -} + const globalObject = globalThis as Record; + const existing = globalObject[defaultNativeRetainerGlobalName]; + if ( + existing && + typeof existing.retain === "function" && + typeof existing.release === "function" && + typeof existing.dispose === "function" + ) { + return existing as NativeRetainer; + } -export function release(value?: unknown): void { - if (arguments.length === 0) { - defaultNativeRetainer.dispose(); - return; + const retainer = createNativeRetainer(); + try { + Object.defineProperty(globalObject, defaultNativeRetainerGlobalName, { + configurable: false, + enumerable: false, + writable: false, + value: retainer, + }); + } catch { + globalObject[defaultNativeRetainerGlobalName] = retainer; } - defaultNativeRetainer.release(value); + return retainer; } const hostViewPropNames = new Set([ @@ -377,11 +802,19 @@ const hostViewPropNames = new Set([ "accessibilityValue", "accessibilityViewIsModal", "children", + "collectChildren", "collapsable", "focusable", "hitSlop", "id", "importantForAccessibility", + "immediateTransactionCommit", + "deferTransactionCommitOnRemovals", + "mountChildrenDirectlyToChildrenView", + "layoutDirectChildrenToChildrenViewBounds", + "emitOffWindowHostReady", + "fabricLifecycleCallbacks", + "ignoreHostReadyWindowAttachment", "nativeID", "needsOffscreenAlphaCompositing", "onAccessibilityAction", @@ -403,6 +836,12 @@ const hostViewPropNames = new Set([ "onStartShouldSetResponder", "onStartShouldSetResponderCapture", "pointerEvents", + "disableDetachedChildrenTouchHandler", + "disableUIKitHostWindowAttachRefresh", + "externalDetachedChildrenOwner", + "preserveDetachedChildrenLayout", + "detachedChildrenContentOffsetX", + "detachedChildrenContentOffsetY", "removeClippedSubviews", "renderToHardwareTextureAndroid", "shouldRasterizeIOS", @@ -411,28 +850,83 @@ const hostViewPropNames = new Set([ ]); function splitUIKitViewProps( - props: Props & UIKitHostViewProps, + props: (Props & UIKitHostViewProps) | undefined, definition: UIKitViewDefinition, ): { nativeProps: ViewProps; pluginProps: Props & UIKitHostViewProps; } { + const normalizedProps = (props ?? {}) as Props & UIKitHostViewProps; const nativeProps: Record = {}; const pluginProps: Record = {}; + const debugName = + definition.debugName || + definition.name || + definition.displayName || + "UIKit"; + let propEntries: [string, unknown][]; - for (const [key, value] of Object.entries(props)) { - if ( - hostViewPropNames.has(key) || - key.startsWith("accessibility") || - key.startsWith("aria-") - ) { - nativeProps[key] = value; - } else { - pluginProps[key] = value; + try { + propEntries = Object.entries(normalizedProps); + } catch (reason) { + throw jsError( + `${debugName} failed to split props: Object.entries is ${typeof Object.entries}; ${jsString(reason)}`, + ); + } + + try { + for (const [key, value] of propEntries) { + if ( + hostViewPropNames.has(key) || + key.startsWith("accessibility") || + key.startsWith("aria-") + ) { + nativeProps[key] = value; + } else { + pluginProps[key] = value; + } + } + } catch (reason) { + throw jsError( + `${debugName} failed to classify props: Set.has is ${typeof hostViewPropNames.has}; String.startsWith is ${typeof "".startsWith}; ${jsString(reason)}`, + ); + } + + let nativePropsMapper: UIKitViewDefinition["nativeProps"]; + try { + nativePropsMapper = Object.prototype.hasOwnProperty.call( + definition, + "nativeProps", + ) + ? definition.nativeProps + : undefined; + } catch (reason) { + throw jsError( + `${debugName} failed to read nativeProps mapper: hasOwnProperty.call is ${typeof Object.prototype.hasOwnProperty.call}; ${jsString(reason)}`, + ); + } + let mappedNativeProps: Partial | undefined; + if (typeof nativePropsMapper === "function") { + try { + mappedNativeProps = nativePropsMapper(normalizedProps); + } catch (reason) { + throw jsError( + `${debugName} nativeProps mapper failed: ${jsString(reason)}`, + ); } + } else if (nativePropsMapper != null) { + mappedNativeProps = nativePropsMapper; } - Object.assign(nativeProps, definition.nativeProps?.(props)); + if (mappedNativeProps != null) { + try { + Object.assign(nativeProps, mappedNativeProps); + } catch (reason) { + throw jsError( + `${debugName} failed to merge nativeProps: Object.assign is ${typeof Object.assign}; ${jsString(reason)}`, + ); + } + } return { nativeProps: nativeProps as ViewProps, @@ -440,1525 +934,3633 @@ function splitUIKitViewProps( }; } -function nativeHandleForUIKitView(view: unknown): string { - "worklet"; +const uikitHostPropsPayloadKey = "__nativeScriptUIKitHostProps"; +const uikitHostPropsRevisionKey = "__nativeScriptUIKitHostPropsRevision"; +const uikitHostFunctionPropMarkerKey = "__nativeScriptUIKitFunctionProp"; - const interop = (globalThis as Record).interop; - if (!interop || typeof interop.handleof !== "function") { - throw new Error("NativeScript interop globals are not installed"); +function isSerializableUIKitHostObject(value: unknown): value is object { + if (value == null || typeof value !== "object") { + return false; } - const pointer = interop.handleof(view); - if (!pointer) { - throw new Error( - "UIKit view definition returned a value without a native handle", - ); + if (Array.isArray(value)) { + return true; } - if (typeof pointer.toHexString === "function") { - const text = pointer.toHexString(); - if (typeof text === "string" && text.length > 0) { - return text; - } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype == null; +} + +function copyUIKitHostPropsForUI(value: unknown, key = ""): unknown { + if (key === "children" || key === "ref" || typeof value === "symbol") { + return undefined; } - if (typeof pointer.address === "string" && pointer.address.length > 0) { - return pointer.address; + if ( + typeof value === "function" || + value == null || + typeof value !== "object" + ) { + return value; } - if (typeof pointer.address === "number") { - return String(pointer.address); + if (!isSerializableUIKitHostObject(value)) { + return undefined; } - if (typeof pointer.toNumber === "function") { - return String(pointer.toNumber()); + if (Array.isArray(value)) { + return value.map((item) => copyUIKitHostPropsForUI(item)); } - throw new Error("UIKit view native handle could not be read"); + const copy: Record = {}; + for (const [childKey, childValue] of Object.entries(value)) { + const copiedValue = copyUIKitHostPropsForUI(childValue, childKey); + if (copiedValue !== undefined) { + copy[childKey] = copiedValue; + } + } + return copy; } -function nativeHandleOrUndefined(value: unknown): string | undefined { - "worklet"; +function stringifySerializableUIKitHostProps(props: Readonly): string { + const seen = new WeakSet(); - return value == null ? undefined : nativeHandleForUIKitView(value); -} + try { + return ( + JSON.stringify(props, (key, value) => { + if (key === "children" || key === "ref" || typeof value === "symbol") { + return undefined; + } + if (typeof value === "function") { + return { [uikitHostFunctionPropMarkerKey]: true }; + } -function nativeHandleForNSObject(value: unknown): string | undefined { - "worklet"; + if (value && typeof value === "object") { + if (!isSerializableUIKitHostObject(value)) { + return undefined; + } + if (seen.has(value)) { + return undefined; + } + seen.add(value); + } - if (value == null) { - return undefined; - } - const interop = (globalThis as Record).interop; - const pointer = interop?.handleof?.(value); - if (!pointer) { - return undefined; - } - if (typeof pointer.toHexString === "function") { - return pointer.toHexString(); - } - if (typeof pointer.address === "string") { - return pointer.address; - } - if (typeof pointer.address === "number") { - return String(pointer.address); - } - if (typeof pointer.toNumber === "function") { - return String(pointer.toNumber()); + return value; + }) ?? "{}" + ); + } catch { + return "{}"; } - return undefined; } -function ensureNativeScriptInstalled(): void { - if (!isInstalled()) { - init(); - } +function stringifyUIKitHostPropsPayload( + serializedPropsJson: string, + revision: number, +): string { + const normalizedPropsJson = + typeof serializedPropsJson === "string" && serializedPropsJson.length > 0 + ? serializedPropsJson + : "{}"; + + return `{"${uikitHostPropsRevisionKey}":${revision},"${uikitHostPropsPayloadKey}":${normalizedPropsJson}}`; } -function defineLazyNativeGlobal( - name: string, - resolve: (name: string) => unknown, - force = false, -) { - if (!name) { - return; - } +function hasNonSerializableUIKitHostProps(value: unknown): boolean { + const seen = new WeakSet(); - if (!force && Object.prototype.hasOwnProperty.call(globalThis, name)) { - const descriptor = Object.getOwnPropertyDescriptor(globalThis, name); - if (descriptor && "value" in descriptor) { - cacheNativeGlobal(name, descriptor.value); + const visit = (key: string, nextValue: unknown): boolean => { + if (key === "children" || key === "ref") { + return false; } - return; - } - try { - Object.defineProperty(globalThis, name, { - configurable: true, - enumerable: false, - get() { - const value = resolve(name); - cacheNativeGlobal(name, value); - Object.defineProperty(globalThis, name, { - configurable: true, - enumerable: false, - writable: false, - value, - }); - return value; - }, - }); - } catch { - const value = resolve(name); - if (value !== undefined) { - cacheNativeGlobal(name, value); - Object.defineProperty(globalThis, name, { - configurable: true, - enumerable: false, - writable: false, - value, - }); + if (typeof nextValue === "function" || typeof nextValue === "symbol") { + return true; } - } -} -function wrapAggregateConstructor(nativeConstructor: unknown): unknown { - if (typeof nativeConstructor !== "function") { - return nativeConstructor; - } + if (nextValue == null || typeof nextValue !== "object") { + return false; + } - const aggregate = function NativeScriptAggregate(initialValue?: unknown) { - return nativeConstructor(initialValue); - }; + if (!isSerializableUIKitHostObject(nextValue)) { + return true; + } - try { - const hasInstance = Symbol.hasInstance; - Object.defineProperty(aggregate, hasInstance, { - configurable: true, - enumerable: false, - value(value: unknown) { - if (!value || typeof value !== "object") { - return false; - } - const actual = value as Record; - return ( - actual.kind === (nativeConstructor as Record).kind && - actual.name === - (nativeConstructor as Record).runtimeName - ); - }, - }); - } catch { - // Older runtimes can expose Symbol.hasInstance as read-only. - } + if (seen.has(nextValue)) { + return false; + } + seen.add(nextValue); - for (const key of [ - "kind", - "runtimeName", - "metadataOffset", - "sizeof", - "fields", - "equals", - ]) { - try { - Object.defineProperty(aggregate, key, { - configurable: true, - enumerable: false, - writable: false, - value: (nativeConstructor as Record)[key], - }); - } catch { - // Best effort metadata copy for runtimes with stricter function objects. + if (Array.isArray(nextValue)) { + return nextValue.some((item) => visit("", item)); } - } - return aggregate; + return Object.entries(nextValue).some(([childKey, childValue]) => + visit(childKey, childValue), + ); + }; + + return visit("", value); } -function wrapNativeClass(nativeClass: unknown): unknown { - if ( - !nativeClass || - (typeof nativeClass !== "object" && typeof nativeClass !== "function") - ) { - return nativeClass; - } +function nonSerializableUIKitHostPropsChanged( + previous: unknown, + next: unknown, +): boolean { + const seen = new WeakMap>(); + + const visit = ( + key: string, + leftValue: unknown, + rightValue: unknown, + ): boolean => { + if (key === "children" || key === "ref") { + return false; + } - const cached = nativeClassWrappers.get(nativeClass as object); - if (cached) { - return cached; - } + const leftIsLive = + typeof leftValue === "function" || typeof leftValue === "symbol"; + const rightIsLive = + typeof rightValue === "function" || typeof rightValue === "symbol"; - const constructable = function NativeScriptNativeClass(...args: unknown[]) { - const cls = nativeClass as Record; - if (args.length > 0 && typeof cls.construct === "function") { - return cls.construct(...args); + if (leftIsLive || rightIsLive) { + return leftValue !== rightValue; } - if (typeof cls.alloc !== "function") { - throw new Error("Native class cannot be allocated"); + + if ( + leftValue == null || + rightValue == null || + typeof leftValue !== "object" || + typeof rightValue !== "object" + ) { + return false; } - const instance = cls.alloc(); - if (instance && typeof instance.init === "function") { - return instance.init(); + + if ( + !isSerializableUIKitHostObject(leftValue) || + !isSerializableUIKitHostObject(rightValue) + ) { + return leftValue !== rightValue; } - return instance; - }; - Object.defineProperty(constructable, "new", { - configurable: true, - enumerable: false, - writable: false, - value(...args: unknown[]) { - if (args.length !== 0) { - throw new Error( - "new does not take arguments; use invoke for an explicit Objective-C selector.", - ); + const previousSeen = seen.get(leftValue); + if (previousSeen?.has(rightValue)) { + return false; + } + + if (previousSeen) { + previousSeen.add(rightValue); + } else { + const nextSeen = new WeakSet(); + nextSeen.add(rightValue); + seen.set(leftValue, nextSeen); + } + + const keys = new Set([ + ...Object.keys(leftValue as Record), + ...Object.keys(rightValue as Record), + ]); + + for (const childKey of keys) { + if ( + visit( + childKey, + (leftValue as Record)[childKey], + (rightValue as Record)[childKey], + ) + ) { + return true; } - return constructable(); - }, - }); + } - Object.defineProperty(constructable, "__nativeApiClass", { - configurable: false, - enumerable: false, - writable: false, - value: nativeClass, - }); - const cachedNativeFunctions = new Map(); + return false; + }; - try { - const hasInstance = Symbol.hasInstance; - Object.defineProperty(constructable, hasInstance, { - configurable: true, - enumerable: false, - value(value: unknown) { - if (!value || typeof value !== "object") { - return false; - } + return visit("", previous, next); +} - const cls = nativeClass as Record; - try { - if ( - typeof (value as Record).isKindOfClass === "function" - ) { - return Boolean( - (value as Record).isKindOfClass(constructable), - ); - } - } catch { - // Fall through to class-name equality for host objects that cannot - // dispatch isKindOfClass from this thread. - } +function isPlainObject(value: unknown): value is Record { + "worklet"; - const expectedName = cls.runtimeName ?? cls.name; - const actualName = (value as Record).className; - return typeof expectedName === "string" && actualName === expectedName; - }, - }); - } catch { - // Older runtimes can expose Symbol.hasInstance as read-only. + if (value == null || typeof value !== "object" || Array.isArray(value)) { + return false; } - const wrapper = new Proxy(constructable, { - get(target, property, receiver) { - if (property in target) { - return Reflect.get(target, property, receiver); - } - if (cachedNativeFunctions.has(property)) { - return cachedNativeFunctions.get(property); - } - const nativeValue = (nativeClass as Record)[ - property - ]; - if (typeof nativeValue === "function") { - cachedNativeFunctions.set(property, nativeValue); - try { - Object.defineProperty(target, property, { - configurable: true, - enumerable: false, - writable: false, - value: nativeValue, - }); - } catch { - // Host runtimes may reject defining function properties; the map is enough. - } - } - return nativeValue; - }, - set(_target, property, value) { - (nativeClass as Record)[property] = value; - return true; - }, - has(target, property) { - return property in target || property in (nativeClass as object); - }, - }); + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype == null; +} - nativeClassWrappers.set(nativeClass as object, wrapper); - return wrapper; +function isUIKitHostFunctionPropMarker(value: unknown): boolean { + "worklet"; + + return ( + isPlainObject(value) && + value[uikitHostFunctionPropMarkerKey] === true && + Object.keys(value).length === 1 + ); } -function wrapInteropFactory( - nativeFactory: unknown, - properties: Record, +function mergeUIKitHostPropsFromNative( + current: unknown, + nativeValue: unknown, ): unknown { - if (typeof nativeFactory !== "function") { - return nativeFactory; + "worklet"; + + if (isUIKitHostFunctionPropMarker(nativeValue)) { + return typeof current === "function" ? current : undefined; } - if ((nativeFactory as Record).__nativeScriptConstructable) { - return nativeFactory; + if (Array.isArray(nativeValue)) { + const currentArray = Array.isArray(current) ? current : []; + return nativeValue.map((item, index) => + mergeUIKitHostPropsFromNative(currentArray[index], item), + ); } - const constructable = function NativeScriptInteropValue(...args: unknown[]) { - return (nativeFactory as (...args: unknown[]) => unknown)(...args); - }; + if (isPlainObject(nativeValue)) { + const currentObject = isPlainObject(current) ? current : undefined; + const merged: Record = {}; - try { - const nativePrototype = (nativeFactory as { prototype?: unknown }) - .prototype; - if ( - nativePrototype && - (typeof nativePrototype === "object" || - typeof nativePrototype === "function") - ) { - constructable.prototype = nativePrototype; + for (const [key, childNativeValue] of Object.entries(nativeValue)) { + const childValue = mergeUIKitHostPropsFromNative( + currentObject?.[key], + childNativeValue, + ); + if ( + childValue !== undefined || + !isUIKitHostFunctionPropMarker(childNativeValue) + ) { + merged[key] = childValue; + } } - } catch { - // Keep construction working even if the host function exposes a fixed prototype. + + return merged; } - try { - const hasInstance = Symbol.hasInstance; - Object.defineProperty(constructable, hasInstance, { - configurable: true, - enumerable: false, - value(value: unknown) { - return ( - Boolean(value) && - typeof value === "object" && - (value as Record).kind === properties.kind - ); - }, - }); - } catch { - // Older runtimes can expose Symbol.hasInstance as read-only. + return nativeValue; +} + +function nativeHandleForUIKitView(view: unknown): string { + "worklet"; + + const interop = (globalThis as Record).interop; + if (!interop || typeof interop.handleof !== "function") { + throw new Error("NativeScript interop globals are not installed"); } - for (const [key, value] of Object.entries(properties)) { - try { - Object.defineProperty(constructable, key, { - configurable: true, - enumerable: false, - writable: false, - value, - }); - } catch { - // Best effort metadata copy for runtimes with stricter function objects. - } + const pointer = interop.handleof(view); + if (!pointer) { + throw new Error( + "UIKit view definition returned a value without a native handle", + ); } - Object.defineProperty(constructable, "__nativeScriptConstructable", { - configurable: false, - enumerable: false, - writable: false, - value: true, - }); + if (typeof pointer.toHexString === "function") { + const text = pointer.toHexString(); + if (typeof text === "string" && text.length > 0) { + return text; + } + } - return constructable; -} + if (typeof pointer.address === "string" && pointer.address.length > 0) { + return pointer.address; + } -function installInteropConstructors(): void { - const interop = (globalThis as Record).interop as - | Record - | undefined; - if (!interop || typeof interop !== "object") { - return; + if (typeof pointer.address === "number") { + return String(pointer.address); } - const sizeof = interop.sizeof; - const pointerType = (interop.types as Record | undefined) - ?.pointer; - let pointerSize: unknown = undefined; - if (typeof sizeof === "function" && pointerType !== undefined) { - try { - pointerSize = sizeof(pointerType); - } catch { - pointerSize = undefined; - } + if (typeof pointer.toNumber === "function") { + return String(pointer.toNumber()); } - interop.Pointer = wrapInteropFactory(interop.Pointer, { - kind: "pointer", - sizeof: pointerSize, - }); - interop.Reference = wrapInteropFactory(interop.Reference, { - kind: "reference", - sizeof: pointerSize, - }); - interop.Block = wrapInteropFactory(interop.Block, { - kind: "block", - sizeof: pointerSize, - }); - interop.FunctionReference = wrapInteropFactory(interop.FunctionReference, { - kind: "functionReference", - sizeof: pointerSize, - }); + throw new Error("UIKit view native handle could not be read"); +} - const types = interop.types as Record | undefined; - if (types && typeof types === "object") { - for (const [name, value] of Object.entries(types)) { - if (typeof value !== "number") { - continue; - } - const boxed = { - valueOf: () => value, - toString: () => String(value), - } as Record; - Object.defineProperty(boxed, nativeApiTypeCodeKey, { - configurable: false, - enumerable: false, - writable: false, - value, - }); - types[name] = boxed; - } +function tryNativeHandleForUIKitView(view: unknown): string | undefined { + "worklet"; + + if (view == null) { + return undefined; } -} -function defineInlineFunction(name: string, value: Function): void { - if (Object.prototype.hasOwnProperty.call(globalThis, name)) { - return; + try { + return nativeHandleForUIKitView(view); + } catch { + return undefined; } - Object.defineProperty(globalThis, name, { - configurable: true, - enumerable: false, - writable: true, - value, - }); } -function installInlineFunctions(): void { - const makePoint = (x: number, y: number) => ({ x, y }); - const makeSize = (width: number, height: number) => ({ width, height }); - const makeRect = (x: number, y: number, width: number, height: number) => ({ - origin: { x, y }, - size: { width, height }, - }); +function nativeHandleOrUndefined(value: unknown): string | undefined { + "worklet"; - defineInlineFunction("CGPointMake", makePoint); - defineInlineFunction("NSMakePoint", makePoint); - defineInlineFunction("CGSizeMake", makeSize); - defineInlineFunction("NSMakeSize", makeSize); - defineInlineFunction("CGRectMake", makeRect); - defineInlineFunction("NSMakeRect", makeRect); - defineInlineFunction("NSMakeRange", (location: number, length: number) => ({ - location, - length, - })); - defineInlineFunction( - "UIEdgeInsetsMake", - (top: number, left: number, bottom: number, right: number) => ({ - top, - left, - bottom, - right, - }), - ); + return value == null ? undefined : nativeHandleForUIKitView(value); } -export function installGlobals(): boolean { - const api = nativeApiHost(); - if (!api) { - return false; - } +function nativeHandleForNSObject(value: unknown): string | undefined { + "worklet"; - const classNames = api.metadata?.classNames?.() ?? []; - for (const name of classNames) { - defineLazyNativeGlobal(name, (className) => - wrapNativeClass(api[className]), - ); + if (value == null) { + return undefined; } - - const functionNames = api.metadata?.functionNames?.() ?? []; - for (const name of functionNames) { - defineLazyNativeGlobal(name, (functionName) => api[functionName]); + const interop = (globalThis as Record).interop; + const pointer = interop?.handleof?.(value); + if (!pointer) { + return undefined; + } + if (typeof pointer.toHexString === "function") { + return pointer.toHexString(); + } + if (typeof pointer.address === "string") { + return pointer.address; + } + if (typeof pointer.address === "number") { + return String(pointer.address); + } + if (typeof pointer.toNumber === "function") { + return String(pointer.toNumber()); } + return undefined; +} - const constantNames = api.metadata?.constantNames?.() ?? []; - for (const name of constantNames) { - defineLazyNativeGlobal(name, (constantName) => api[constantName]); +function tryNativeHandleForNSObject(value: unknown): string | undefined { + "worklet"; + + try { + return nativeHandleForNSObject(value); + } catch { + return undefined; } +} - const protocolNames = api.metadata?.protocolNames?.() ?? []; - for (const name of protocolNames) { - defineLazyNativeGlobal( - name, - (protocolName) => api.getProtocol?.(protocolName) ?? api[protocolName], - ); +/** + * Stable string handle for a native object, safe to carry across worklet + * boundaries. Returns `undefined` for non-native values. + */ +export function nativeHandleForObject(value: unknown): string | undefined { + "worklet"; + + return nativeHandleForNSObject(value); +} + +export type ObjCSelectorArgument = + | boolean + | number + | string + | null + | undefined + | readonly ObjCSelectorArgument[] + | object; + +type EncodedObjCSelectorArgument = + boolean | number | string | null | undefined | EncodedObjCSelectorArgument[]; + +function encodeObjCSelectorArgument( + arg: ObjCSelectorArgument, +): { ok: true; value: EncodedObjCSelectorArgument } | { ok: false } { + "worklet"; + + if (arg == null || typeof arg === "boolean" || typeof arg === "number") { + return { ok: true, value: arg }; } - const enumNames = api.metadata?.enumNames?.() ?? []; - for (const name of enumNames) { - const resolveEnum = (enumName: string) => - api.getEnum?.(enumName) ?? api[enumName]; - defineLazyNativeGlobal(name, resolveEnum); + if (typeof arg === "string") { + return { ok: true, value: arg }; + } - const enumValue = resolveEnum(name); - if (!enumValue || typeof enumValue !== "object") { - continue; - } - for (const memberName of Object.keys(enumValue)) { - if (/^-?\d+$/.test(memberName)) { - continue; + if (Array.isArray(arg)) { + const encodedItems: EncodedObjCSelectorArgument[] = []; + for (const item of arg) { + const encodedItem = encodeObjCSelectorArgument(item); + if (!encodedItem.ok) { + return { ok: false }; } - defineLazyNativeGlobal( - memberName, - () => (enumValue as Record)[memberName], - ); + encodedItems.push(encodedItem.value); } + return { ok: true, value: encodedItems }; } - const structNames = api.metadata?.structNames?.() ?? []; - for (const name of structNames) { - defineLazyNativeGlobal( - name, - (structName) => - wrapAggregateConstructor( - api.getStruct?.(structName) ?? api[structName], - ), - true, - ); + const handle = tryNativeHandleForNSObject(arg); + if (typeof handle !== "string" || handle.length === 0) { + return { ok: false }; } + return { ok: true, value: handle }; +} - const unionNames = api.metadata?.unionNames?.() ?? []; - for (const name of unionNames) { - defineLazyNativeGlobal( - name, - (unionName) => - wrapAggregateConstructor(api.getUnion?.(unionName) ?? api[unionName]), - true, - ); - } +function objectFromNativePointerValue( + interop: Record, + pointerValue: string | number, +): T | null { + "worklet"; - return true; + try { + return (interop.object(interop.Pointer(pointerValue)) ?? null) as T | null; + } catch { + return null; + } } +function isFiniteNumber(value: unknown): value is number { + "worklet"; -export function init(metadataPath = "", options: InstallOptions = {}): boolean { - const installed = - NativeScriptNativeApi.isInstalled() || - NativeScriptNativeApi.install(metadataPath); - if (installed) { - installInteropConstructors(); - installInlineFunctions(); - } - if (installed && options.globals === true) { - installGlobals(); - } - if (installed) { - ensureWorkletsInstalled(metadataPath); - } - return installed; + return ( + typeof value === "number" && + value === value && + value !== Infinity && + value !== -Infinity + ); } +function nativePointerAddressFromHandle( + handle: string | number | null | undefined, +): number | null { + "worklet"; -export const install = init; - -export function isInstalled(): boolean { - return NativeScriptNativeApi.isInstalled(); -} + if (typeof handle === "number") { + return isFiniteNumber(handle) && handle > 0 ? handle : null; + } -export function defaultMetadataPath(): string { - return NativeScriptNativeApi.defaultMetadataPath(); -} + if (typeof handle !== "string") { + return null; + } -export function getRuntimeBackend(): string { - return NativeScriptNativeApi.getRuntimeBackend(); -} + const trimmed = handle.trim(); + if (trimmed.length === 0) { + return null; + } -let workletsAdapter: NativeScriptWorklets | undefined; -const workletsPackageName = "react-native-worklets"; + const address = Number(trimmed); + if (!isFiniteNumber(address) || address <= 0) { + return null; + } -function workletsSetupError(reason: string): Error { - return new Error( - `${reason}. Install ${workletsPackageName}, add ${workletsPackageName}/plugin to your Babel plugins, and run pod install so RNWorklets is linked.`, - ); + return address; } +function resolveNativeObjectFromHandle( + handle: string | number | null | undefined, +): T | null { + "worklet"; -function requireReactNativeWorklets(): NativeScriptWorklets { - try { - return require(workletsPackageName) as NativeScriptWorklets; - } catch (error) { - throw workletsSetupError( - `NativeScript.runOnUI requires ${workletsPackageName}`, - ); + if (handle == null || handle === "") { + return null; } -} -function validateWorkletsModule( - worklets: NativeScriptWorklets, -): NativeScriptWorklets { + const interop = (globalThis as Record).interop; if ( - worklets == null || - typeof worklets.getUIRuntimeHolder !== "function" || - typeof worklets.isWorkletFunction !== "function" || - typeof worklets.runOnUIAsync !== "function" + !interop || + typeof interop.object !== "function" || + typeof interop.Pointer !== "function" ) { - throw workletsSetupError( - "NativeScript.runOnUI received an incompatible Worklets module", + return null; + } + + if (typeof handle === "string") { + const trimmed = handle.trim(); + if (trimmed.length === 0) { + return null; + } + + const objectFromStringPointer = objectFromNativePointerValue( + interop, + trimmed, ); + if (objectFromStringPointer != null) { + return objectFromStringPointer; + } } - return worklets; + + const address = nativePointerAddressFromHandle(handle); + if (address == null) { + return null; + } + + return objectFromNativePointerValue(interop, address); } +/** + * Resolve a handle from {@link nativeHandleForObject} back to its native object + * on the UI runtime. String handles round-trip exactly; numeric coercion is a + * lossy fallback. Returns `null` when unresolvable. + */ +export function nativeObjectFromHandle( + handle: string | number | null | undefined, +): T | null { + "worklet"; -function installIdleAwareWorkletsFrameLoop(): boolean { + return resolveNativeObjectFromHandle(handle); +} +/** + * Send an arbitrary Objective-C selector to a native target and re-wrap a native + * object result. Accepts encoded selector arguments, including nested arrays. + */ +export function invokeObjCSelector( + target: unknown, + selectorName: string, + args: readonly ObjCSelectorArgument[] = [], +): ReturnValue | boolean | null { "worklet"; - const globalObject = globalThis as Record; - if (globalObject.__nativeScriptIdleAwareWorkletsFrameLoop === true) { - return true; + const invoke = (globalThis as Record) + .__nativeScriptInvokeObjCSelector; + if (typeof invoke !== "function") { + return false; } - const nativeRequestAnimationFrame = - globalObject.__nativeRequestAnimationFrame; - const callMicrotasks = globalObject.__callMicrotasks; - - if ( - typeof nativeRequestAnimationFrame !== "function" || - typeof callMicrotasks !== "function" - ) { + const targetHandle = tryNativeHandleForNSObject(target); + if (typeof targetHandle !== "string" || targetHandle.length === 0) { return false; } - globalObject.__nativeScriptIdleAwareWorkletsFrameLoop = true; - globalObject.__nativeScriptNativeRequestAnimationFrame = - nativeRequestAnimationFrame; + const selectorArgs: EncodedObjCSelectorArgument[] = []; + for (const arg of args) { + const encodedArg = encodeObjCSelectorArgument(arg); + if (!encodedArg.ok) { + return false; + } + selectorArgs.push(encodedArg.value); + } - let queuedCallbacks: Array<(timestamp: number) => void> = []; - let queuedCallbacksBegin = 0; - let queuedCallbacksEnd = 0; - let flushedCallbacks = queuedCallbacks; - let flushedCallbacksBegin = 0; - let flushedCallbacksEnd = 0; - let queuedFinalizers: Array<() => void> = []; - let nativeFlushScheduled = false; + const result = invoke(targetHandle, selectorName, selectorArgs); + if (typeof result === "string") { + const object = nativeObjectFromHandle(result); + return object ?? (result as ReturnValue); + } - const NSTimerClass = globalObject.NSTimer; - const NSRunLoopClass = globalObject.NSRunLoop; - if ( - NSTimerClass == null || - NSRunLoopClass == null || - NSRunLoopClass.mainRunLoop == null - ) { - throw new Error("NativeScript Worklets timers require NSTimer/NSRunLoop"); + return result as ReturnValue | boolean | null; +} + +function truncateNumber(value: number): number { + "worklet"; + + return value < 0 ? Math.ceil(value) : Math.floor(value); +} + +/** + * Length of a bridged `NSArray`/`NSOrderedSet` without assuming a JS array shape + * (falls back through `count` / `objectAtIndex:`-style access). + */ +export function nativeArrayLength(value: unknown): number { + "worklet"; + + if (value == null) { + return 0; } - type NativeTimer = { invalidate?: () => void }; - const nativeTimers = new Map(); - let nextNativeTimerHandle = 1; + const arrayLike = value as Record; + const count = arrayLike.count; + if (isFiniteNumber(count)) { + return Math.max(0, count); + } + if (typeof count === "function") { + try { + const resolvedCount = count.call(value); + if (isFiniteNumber(resolvedCount)) { + return Math.max(0, resolvedCount); + } + } catch { + // Fall through to JS-array length for non-NSArray objects. + } + } - function runtimeTimerInvoker any>( - callback: T, - ): T { - const wrapped = function nativeScriptWorkletTimerCallback( - this: unknown, - ...args: unknown[] - ) { - return callback.apply(this, args); - } as T; - Object.defineProperties(wrapped, { - __nativeScriptCallbackThread: { - configurable: false, - enumerable: false, - writable: false, - value: "runtime", - }, - __nativeScriptWrappedCallback: { - configurable: false, - enumerable: false, - writable: false, - value: callback, - }, - }); - return wrapped; + const length = arrayLike.length; + if (isFiniteNumber(length)) { + return Math.max(0, length); } - function normalizeTimerDelay(delay: unknown): number { - const numericDelay = - typeof delay === "number" && Number.isFinite(delay) ? delay : 0; - return Math.max(0.001, numericDelay / 1000); + const selectorCount = invokeObjCSelector(value, "count"); + if (isFiniteNumber(selectorCount)) { + return Math.max(0, selectorCount); } - function scheduleNativeTimer( - callback: (...args: unknown[]) => void, - delay: unknown, - repeats: boolean, - args: unknown[], - ): number { - if (typeof callback !== "function") { - throw new TypeError("NativeScript Worklets timer expects a callback"); - } + return 0; +} - const handle = nextNativeTimerHandle++; - const fireTimer = runtimeTimerInvoker((timer: NativeTimer) => { - if (!nativeTimers.has(handle)) { - return; - } - if (!repeats) { - nativeTimers.delete(handle); - } - callback(...args); - callMicrotasks(); - if (!repeats) { - timer?.invalidate?.(); - } - }); +/** + * Element at `index` of a bridged native collection, mirroring + * {@link nativeArrayLength}'s shape-agnostic access. + */ +export function nativeArrayItem( + value: unknown, + index: number, +): T | null { + "worklet"; - const interval = normalizeTimerDelay(delay); - const timer = - typeof NSTimerClass.timerWithTimeIntervalRepeatsBlock === "function" - ? NSTimerClass.timerWithTimeIntervalRepeatsBlock( - interval, - repeats, - fireTimer, - ) - : NSTimerClass.scheduledTimerWithTimeIntervalRepeatsBlock( - interval, - repeats, - fireTimer, - ); + if (value == null || !isFiniteNumber(index)) { + return null; + } - nativeTimers.set(handle, timer); - if (typeof NSTimerClass.timerWithTimeIntervalRepeatsBlock === "function") { - NSRunLoopClass.mainRunLoop.addTimerForMode( - timer, - "kCFRunLoopCommonModes", - ); - } - return handle; + const normalizedIndex = truncateNumber(index); + const count = nativeArrayLength(value); + if (normalizedIndex < 0 || normalizedIndex >= count) { + return null; } - function clearNativeTimer(handle: unknown) { - if (typeof handle !== "number") { - return; - } - const timer = nativeTimers.get(handle); - nativeTimers.delete(handle); - timer?.invalidate?.(); + const arrayLike = value as Record; + if (typeof arrayLike.objectAtIndex === "function") { + return (arrayLike.objectAtIndex(normalizedIndex) ?? null) as T | null; + } + if (typeof arrayLike.objectAtIndexedSubscript === "function") { + return (arrayLike.objectAtIndexedSubscript(normalizedIndex) ?? + null) as T | null; } - function hasPendingFrameWork() { - return queuedCallbacks.length > 0 || queuedFinalizers.length > 0; + const selectorItem = invokeObjCSelector(value, "objectAtIndex:", [ + normalizedIndex, + ]); + if (selectorItem !== false && selectorItem !== undefined) { + return (selectorItem ?? null) as T | null; } - function executeQueue(timestamp: number) { - flushedCallbacks = queuedCallbacks; - queuedCallbacks = []; + return (arrayLike[normalizedIndex] ?? null) as T | null; +} - flushedCallbacksBegin = queuedCallbacksBegin; - flushedCallbacksEnd = queuedCallbacksEnd; - queuedCallbacksBegin = queuedCallbacksEnd; +/** + * Snapshot a `UIView`'s subviews as a JS array. Call on the UI runtime; the + * result is a copy, so later UIKit mutations are not reflected. + */ +export function nativeSubviews(view: unknown): T[] { + "worklet"; - for (const callback of flushedCallbacks) { - callback(timestamp); + const subviews = (view as Record | null | undefined) + ?.subviews; + const count = nativeArrayLength(subviews); + if (count === 0) { + return []; + } + + const result: T[] = []; + for (let index = 0; index < count; index += 1) { + const subview = nativeArrayItem(subviews, index); + if (subview != null) { + result.push(subview); } + } + return result; +} - flushedCallbacksBegin = flushedCallbacksEnd; - callMicrotasks(); +/** + * Read the Fabric child views a `collectChildren` host exposed instead of + * mounting them. Empty when the host is not in collect mode. + */ +export function collectedUIKitHostChildren(view: unknown): T[] { + "worklet"; - const finalizers = queuedFinalizers; - queuedFinalizers = []; - for (const finalizer of finalizers) { - finalizer(); - } + const getCollectedChildren = (globalThis as Record) + .__nativeScriptCollectedUIKitHostChildren; + if (typeof getCollectedChildren !== "function") { + return []; } - function flushQueue(timestamp: number) { - globalObject.__frameTimestamp = timestamp; - executeQueue(timestamp); - globalObject.__frameTimestamp = undefined; + const viewHandle = tryNativeHandleForUIKitView(view); + if (typeof viewHandle !== "string" || viewHandle.length === 0) { + return []; } - function nativeFlushQueue(timestamp: number) { - nativeFlushScheduled = false; - flushQueue(timestamp); - if (hasPendingFrameWork()) { - scheduleNativeFlush(); - } + const collectedChildrenHandle = getCollectedChildren(viewHandle); + const collectedChildren = + typeof collectedChildrenHandle === "string" + ? nativeObjectFromHandle(collectedChildrenHandle) + : collectedChildrenHandle; + const count = nativeArrayLength(collectedChildren); + if (count === 0) { + return []; } - function scheduleNativeFlush() { - if (nativeFlushScheduled) { - return; + const result: T[] = []; + for (let index = 0; index < count; index += 1) { + const child = nativeArrayItem(collectedChildren, index); + if (child != null) { + result.push(child); } - nativeFlushScheduled = true; - nativeRequestAnimationFrame(nativeFlushQueue); } + return result; +} - globalObject.requestAnimationFrame = ( - callback: (timestamp: number) => void, - ): number => { - const handle = queuedCallbacksEnd; - queuedCallbacksEnd += 1; - queuedCallbacks.push(callback); - scheduleNativeFlush(); - return handle; - }; +/** + * Native handles (component / container / native / children / controller) for a + * hosted view, or `null` if the view is not a NativeScript host. + */ +export function uikitHostHandlesForView( + view: unknown, +): UIKitHostNativeHandles | null { + "worklet"; - globalObject.cancelAnimationFrame = (handle: number) => { - if (handle < flushedCallbacksBegin || handle >= queuedCallbacksEnd) { - return; + const getHostHandles = (globalThis as Record) + .__nativeScriptUIKitHostHandlesForView; + if (typeof getHostHandles !== "function") { + return null; + } + + const viewHandle = tryNativeHandleForUIKitView(view); + if (typeof viewHandle !== "string" || viewHandle.length === 0) { + return null; + } + + const handles = getHostHandles(viewHandle); + if (handles == null || typeof handles !== "object") { + return null; + } + + const componentViewHandle = + typeof handles.componentViewHandle === "string" && + handles.componentViewHandle.length > 0 + ? handles.componentViewHandle + : undefined; + const containerViewHandle = + typeof handles.containerViewHandle === "string" && + handles.containerViewHandle.length > 0 + ? handles.containerViewHandle + : undefined; + const nativeViewHandle = + typeof handles.nativeViewHandle === "string" && + handles.nativeViewHandle.length > 0 + ? handles.nativeViewHandle + : undefined; + const childrenViewHandle = + typeof handles.childrenViewHandle === "string" && + handles.childrenViewHandle.length > 0 + ? handles.childrenViewHandle + : undefined; + const controllerHandle = + typeof handles.controllerHandle === "string" && + handles.controllerHandle.length > 0 + ? handles.controllerHandle + : undefined; + + if ( + !componentViewHandle && + !containerViewHandle && + !nativeViewHandle && + !childrenViewHandle && + !controllerHandle + ) { + return null; + } + + return { + componentViewHandle, + containerViewHandle, + nativeViewHandle, + childrenViewHandle, + controllerHandle, + }; +} + +function setAssociatedNativeObject( + target: unknown, + key: string, + value: unknown, + policy: NativeAssociationPolicy = "retainNonatomic", +): boolean { + "worklet"; + + if (target == null || !key) { + return false; + } + + const setAssociatedObject = (globalThis as Record).interop + ?.setAssociatedObject; + if (typeof setAssociatedObject !== "function") { + return false; + } + + setAssociatedObject(target, key, value ?? null, policy); + return true; +} + +function ensureNativeScriptInstalled(): void { + if (!isInstalled()) { + init(); + } +} + +function defineLazyNativeGlobal( + name: string, + resolve: (name: string) => unknown, + force = false, +) { + if (!name) { + return; + } + + if (!force && Object.prototype.hasOwnProperty.call(globalThis, name)) { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, name); + if (descriptor && "value" in descriptor) { + cacheNativeGlobal(name, descriptor.value); + } + return; + } + + try { + Object.defineProperty(globalThis, name, { + configurable: true, + enumerable: false, + get() { + const value = resolve(name); + cacheNativeGlobal(name, value); + Object.defineProperty(globalThis, name, { + configurable: true, + enumerable: false, + writable: false, + value, + }); + return value; + }, + }); + } catch { + const value = resolve(name); + if (value !== undefined) { + cacheNativeGlobal(name, value); + Object.defineProperty(globalThis, name, { + configurable: true, + enumerable: false, + writable: false, + value, + }); + } + } +} + +function wrapAggregateConstructor(nativeConstructor: unknown): unknown { + if (typeof nativeConstructor !== "function") { + return nativeConstructor; + } + + const aggregate = function NativeScriptAggregate(initialValue?: unknown) { + return nativeConstructor(initialValue); + }; + + try { + const hasInstance = Symbol.hasInstance; + Object.defineProperty(aggregate, hasInstance, { + configurable: true, + enumerable: false, + value(value: unknown) { + if (!value || typeof value !== "object") { + return false; + } + const actual = value as Record; + return ( + actual.kind === (nativeConstructor as Record).kind && + actual.name === + (nativeConstructor as Record).runtimeName + ); + }, + }); + } catch { + // Older runtimes can expose Symbol.hasInstance as read-only. + } + + for (const key of [ + "kind", + "runtimeName", + "metadataOffset", + "sizeof", + "fields", + "equals", + ]) { + try { + Object.defineProperty(aggregate, key, { + configurable: true, + enumerable: false, + writable: false, + value: (nativeConstructor as Record)[key], + }); + } catch { + // Best effort metadata copy for runtimes with stricter function objects. + } + } + + return aggregate; +} + +function rememberNativeObjectClass(value: T, classWrapper: unknown): T { + "worklet"; + + if ( + value == null || + (typeof value !== "object" && typeof value !== "function") || + (typeof classWrapper !== "object" && typeof classWrapper !== "function") + ) { + return value; + } + + const rememberObjectClassWrapper = (nativeApiHost() as Record) + ?.__rememberObjectClassWrapper; + if (typeof rememberObjectClassWrapper === "function") { + try { + rememberObjectClassWrapper(value, classWrapper); + } catch { + // The native object still works without the expando; remembering only + // improves constructor/prototype fidelity for runtime-generated classes. + } + } + + return value; +} + +function wrapNativeClass(nativeClass: unknown): unknown { + "worklet"; + + if ( + !nativeClass || + (typeof nativeClass !== "object" && typeof nativeClass !== "function") + ) { + return nativeClass; + } + + const wrapperCache = nativeApiClassWrapperCache(); + const cached = wrapperCache.get(nativeClass as object); + if (cached) { + return cached; + } + + const rememberInstanceClass = (value: T): T => + rememberNativeObjectClass(value, wrapper || constructable); + + const constructable = function NativeScriptNativeClass(...args: unknown[]) { + const cls = nativeClass as Record; + if (args.length > 0 && typeof cls.construct === "function") { + return rememberInstanceClass(cls.construct(...args)); + } + if (typeof cls.new === "function") { + return rememberInstanceClass(cls.new()); + } + if (typeof cls.alloc !== "function") { + throw new Error("Native class cannot be allocated"); + } + const instance = rememberInstanceClass(cls.alloc()); + if (instance && typeof instance.init === "function") { + return rememberInstanceClass(instance.init()); + } + return instance; + }; + let wrapper: unknown = constructable; + + Object.defineProperty(constructable, "construct", { + configurable: true, + enumerable: false, + writable: false, + value(...args: unknown[]) { + const cls = nativeClass as Record; + if (typeof cls.construct !== "function") { + throw new Error("Native class cannot construct an explicit pointer"); + } + return rememberInstanceClass(cls.construct(...args)); + }, + }); + + Object.defineProperty(constructable, "alloc", { + configurable: true, + enumerable: false, + writable: false, + value(...args: unknown[]) { + if (args.length !== 0) { + throw new Error( + "alloc does not take arguments; use invoke for an explicit Objective-C selector.", + ); + } + const cls = nativeClass as Record; + if (typeof cls.alloc !== "function") { + throw new Error("Native class cannot be allocated"); + } + return rememberInstanceClass(cls.alloc()); + }, + }); + + Object.defineProperty(constructable, "new", { + configurable: true, + enumerable: false, + writable: false, + value(...args: unknown[]) { + if (args.length !== 0) { + throw new Error( + "new does not take arguments; use invoke for an explicit Objective-C selector.", + ); + } + const cls = nativeClass as Record; + if (typeof cls.new === "function") { + return rememberInstanceClass(cls.new()); + } + return constructable(); + }, + }); + + Object.defineProperty(constructable, "extend", { + configurable: true, + enumerable: false, + writable: false, + value(methods: object, options: object = {}) { + const api = requireNativeApiHost() as Record; + const extendClass = api.__extendClass; + if (typeof extendClass !== "function") { + throw new Error( + "NativeScript Native API class extension is unavailable", + ); + } + if (methods == null || typeof methods !== "object") { + throw new Error("extend() first parameter must be an object"); + } + + const extendedNativeClass = extendClass( + nativeClass, + methods, + options ?? {}, + ); + const extended = wrapNativeClass(extendedNativeClass); + try { + if ( + extended != null && + (typeof extended === "object" || typeof extended === "function") && + (typeof wrapper === "object" || typeof wrapper === "function") + ) { + Object.setPrototypeOf(extended, wrapper as object); + } + } catch { + // Older engines may reject prototype mutation for host-backed functions. + } + + const rememberClassWrapper = api.__rememberClassWrapper; + if (typeof rememberClassWrapper === "function") { + try { + // Pass only the class value (arg 2), NOT the wrapper's bare/empty + // prototype (arg 3): a 2-arg call makes the native side skip + // rememberClassPrototype, so it no longer registers an empty + // prototype as the subclass's canonical prototype and re-prototypes + // instances onto it. That empty-prototype registration (from + // 3fd29322) poisoned first-access inherited-selector resolution for + // ClassBuilder subclasses. Keeping arg 2 preserves constructor / + // instanceof fidelity via rememberClassValue. + rememberClassWrapper(extendedNativeClass, extended); + } catch { + // The WeakMap cache above is enough for JS-side reuse. + } + } + return extended; + }, + }); + + Object.defineProperty(constructable, "__nativeApiClass", { + configurable: false, + enumerable: false, + writable: false, + value: nativeClass, + }); + const cachedNativeFunctions = new Map(); + + try { + const hasInstance = Symbol.hasInstance; + Object.defineProperty(constructable, hasInstance, { + configurable: true, + enumerable: false, + value(value: unknown) { + if (!value || typeof value !== "object") { + return false; + } + + const cls = nativeClass as Record; + try { + if ( + typeof (value as Record).isKindOfClass === "function" + ) { + return Boolean( + (value as Record).isKindOfClass(constructable), + ); + } + } catch { + // Fall through to class-name equality for host objects that cannot + // dispatch isKindOfClass from this thread. + } + + const expectedName = cls.runtimeName ?? cls.name; + const actualName = (value as Record).className; + return typeof expectedName === "string" && actualName === expectedName; + }, + }); + } catch { + // Older runtimes can expose Symbol.hasInstance as read-only. + } + + wrapper = new Proxy(constructable, { + get(target, property, receiver) { + if (Object.prototype.hasOwnProperty.call(target, property)) { + return Reflect.get(target, property, receiver); + } + if (cachedNativeFunctions.has(property)) { + return cachedNativeFunctions.get(property); + } + const nativeValue = (nativeClass as Record)[ + property + ]; + if (typeof nativeValue === "function") { + cachedNativeFunctions.set(property, nativeValue); + try { + Object.defineProperty(target, property, { + configurable: true, + enumerable: false, + writable: false, + value: nativeValue, + }); + } catch { + // Host runtimes may reject defining function properties; the map is enough. + } + } + if (nativeValue !== undefined) { + return nativeValue; + } + return Reflect.get(target, property, receiver); + }, + set(_target, property, value) { + (nativeClass as Record)[property] = value; + return true; + }, + has(target, property) { + return property in target || property in (nativeClass as object); + }, + }); + + wrapperCache.set(nativeClass as object, wrapper); + return wrapper; +} + +function wrapInteropFactory( + nativeFactory: unknown, + properties: Record, +): unknown { + if (typeof nativeFactory !== "function") { + return nativeFactory; + } + + if ((nativeFactory as Record).__nativeScriptConstructable) { + return nativeFactory; + } + + const constructable = function NativeScriptInteropValue(...args: unknown[]) { + return (nativeFactory as (...args: unknown[]) => unknown)(...args); + }; + + try { + const nativePrototype = (nativeFactory as { prototype?: unknown }) + .prototype; + if ( + nativePrototype && + (typeof nativePrototype === "object" || + typeof nativePrototype === "function") + ) { + constructable.prototype = nativePrototype; + } + } catch { + // Keep construction working even if the host function exposes a fixed prototype. + } + + try { + const hasInstance = Symbol.hasInstance; + Object.defineProperty(constructable, hasInstance, { + configurable: true, + enumerable: false, + value(value: unknown) { + return ( + Boolean(value) && + typeof value === "object" && + (value as Record).kind === properties.kind + ); + }, + }); + } catch { + // Older runtimes can expose Symbol.hasInstance as read-only. + } + + for (const [key, value] of Object.entries(properties)) { + try { + Object.defineProperty(constructable, key, { + configurable: true, + enumerable: false, + writable: false, + value, + }); + } catch { + // Best effort metadata copy for runtimes with stricter function objects. + } + } + + Object.defineProperty(constructable, "__nativeScriptConstructable", { + configurable: false, + enumerable: false, + writable: false, + value: true, + }); + + return constructable; +} + +function installInteropConstructors(): void { + const interop = (globalThis as Record).interop as + Record | undefined; + if (!interop || typeof interop !== "object") { + return; + } + + const sizeof = interop.sizeof; + const pointerType = (interop.types as Record | undefined) + ?.pointer; + let pointerSize: unknown = undefined; + if (typeof sizeof === "function" && pointerType !== undefined) { + try { + pointerSize = sizeof(pointerType); + } catch { + pointerSize = undefined; + } + } + + interop.Pointer = wrapInteropFactory(interop.Pointer, { + kind: "pointer", + sizeof: pointerSize, + }); + interop.Reference = wrapInteropFactory(interop.Reference, { + kind: "reference", + sizeof: pointerSize, + }); + interop.Block = wrapInteropFactory(interop.Block, { + kind: "block", + sizeof: pointerSize, + }); + interop.FunctionReference = wrapInteropFactory(interop.FunctionReference, { + kind: "functionReference", + sizeof: pointerSize, + }); + + const types = interop.types as Record | undefined; + if (types && typeof types === "object") { + for (const [name, value] of Object.entries(types)) { + if (typeof value !== "number") { + continue; + } + const boxed = { + valueOf: () => value, + toString: () => String(value), + } as Record; + Object.defineProperty(boxed, nativeApiTypeCodeKey, { + configurable: false, + enumerable: false, + writable: false, + value, + }); + types[name] = boxed; + } + } +} + +function defineInlineFunction(name: string, value: Function): void { + if (Object.prototype.hasOwnProperty.call(globalThis, name)) { + return; + } + Object.defineProperty(globalThis, name, { + configurable: true, + enumerable: false, + writable: true, + value, + }); +} + +function installInlineFunctions(): void { + const makePoint = (x: number, y: number) => ({ x, y }); + const makeSize = (width: number, height: number) => ({ width, height }); + const makeRect = (x: number, y: number, width: number, height: number) => ({ + origin: { x, y }, + size: { width, height }, + }); + + defineInlineFunction("CGPointMake", makePoint); + defineInlineFunction("NSMakePoint", makePoint); + defineInlineFunction("CGSizeMake", makeSize); + defineInlineFunction("NSMakeSize", makeSize); + defineInlineFunction("CGRectMake", makeRect); + defineInlineFunction("NSMakeRect", makeRect); + defineInlineFunction("NSMakeRange", (location: number, length: number) => ({ + location, + length, + })); + defineInlineFunction( + "UIEdgeInsetsMake", + (top: number, left: number, bottom: number, right: number) => ({ + top, + left, + bottom, + right, + }), + ); +} + +function installGlobals(): boolean { + const api = nativeApiHost(); + if (!api) { + return false; + } + + const classNames = api.metadata?.classNames?.() ?? []; + for (const name of classNames) { + defineLazyNativeGlobal(name, (className) => + wrapNativeClass(api[className]), + ); + } + + const functionNames = api.metadata?.functionNames?.() ?? []; + for (const name of functionNames) { + defineLazyNativeGlobal(name, (functionName) => api[functionName]); + } + + const constantNames = api.metadata?.constantNames?.() ?? []; + for (const name of constantNames) { + defineLazyNativeGlobal(name, (constantName) => api[constantName]); + } + + const protocolNames = api.metadata?.protocolNames?.() ?? []; + for (const name of protocolNames) { + defineLazyNativeGlobal( + name, + (protocolName) => api.getProtocol?.(protocolName) ?? api[protocolName], + ); + } + + const enumNames = api.metadata?.enumNames?.() ?? []; + for (const name of enumNames) { + const resolveEnum = (enumName: string) => + api.getEnum?.(enumName) ?? api[enumName]; + defineLazyNativeGlobal(name, resolveEnum); + + const enumValue = resolveEnum(name); + if (!enumValue || typeof enumValue !== "object") { + continue; + } + for (const memberName of Object.keys(enumValue)) { + if (/^-?\d+$/.test(memberName)) { + continue; + } + defineLazyNativeGlobal( + memberName, + () => (enumValue as Record)[memberName], + ); + } + } + + const structNames = api.metadata?.structNames?.() ?? []; + for (const name of structNames) { + defineLazyNativeGlobal( + name, + (structName) => + wrapAggregateConstructor( + api.getStruct?.(structName) ?? api[structName], + ), + true, + ); + } + + const unionNames = api.metadata?.unionNames?.() ?? []; + for (const name of unionNames) { + defineLazyNativeGlobal( + name, + (unionName) => + wrapAggregateConstructor(api.getUnion?.(unionName) ?? api[unionName]), + true, + ); + } + + return true; +} + +/** + * Bootstrap the runtime: attach the Native API host to `globalThis`, install the + * lazy NativeScript-style globals, and install the Native API into the Worklets + * UI runtime. Call once, early. Pass `{ globals: true }` only to also publish + * Objective-C classes on the RN JS thread (off by default — reach UIKit via + * worklets). Returns whether the runtime is installed. + */ +export function init(metadataPath = "", options: InstallOptions = {}): boolean { + const installed = + NativeScriptNativeApi.isInstalled() || + NativeScriptNativeApi.install(metadataPath); + if (installed) { + installInteropConstructors(); + installInlineFunctions(); + } + if (installed && options.globals === true) { + installGlobals(); + } + if (installed) { + ensureWorkletsInstalled(metadataPath); + } + return installed; +} + +function isInstalled(): boolean { + return NativeScriptNativeApi.isInstalled(); +} + +function defaultMetadataPath(): string { + return NativeScriptNativeApi.defaultMetadataPath(); +} + +function getRuntimeBackend(): string { + return NativeScriptNativeApi.getRuntimeBackend(); +} + +let workletsAdapter: NativeScriptWorklets | undefined; +const workletsPackageName = "react-native-worklets"; + +function formatWorkletsSetupCause(cause: unknown): string | undefined { + if (cause instanceof Error) { + return cause.message; + } + if (cause != null && typeof cause === "object") { + const errorLike = cause as { message?: unknown }; + if (typeof errorLike.message === "string") { + return errorLike.message; + } + } + if (typeof cause === "string") { + return cause; + } + return undefined; +} + +function workletsSetupError(reason: string, cause?: unknown): Error { + const causeMessage = formatWorkletsSetupCause(cause); + const setupError = new Error( + `${causeMessage ? `${reason}: ${causeMessage}` : reason}. Install ${workletsPackageName}, add ${workletsPackageName}/plugin to your Babel plugins, and run pod install so RNWorklets is linked.`, + ) as Error & { cause?: unknown }; + if (cause !== undefined) { + setupError.cause = cause; + } + return setupError; +} + +function requireReactNativeWorklets(): NativeScriptWorklets { + try { + return require(workletsPackageName) as NativeScriptWorklets; + } catch (error) { + throw workletsSetupError( + `NativeScript.runOnUI requires ${workletsPackageName}`, + error, + ); + } +} + +function validateWorkletsModule( + worklets: NativeScriptWorklets, +): NativeScriptWorklets { + if ( + worklets == null || + typeof worklets.getUIRuntimeHolder !== "function" || + typeof worklets.isWorkletFunction !== "function" || + typeof worklets.runOnUIAsync !== "function" || + typeof worklets.runOnUISync !== "function" + ) { + throw workletsSetupError( + "NativeScript.runOnUI received an incompatible Worklets module", + ); + } + return worklets; +} + +function installIdleAwareWorkletsFrameLoop(): boolean { + "worklet"; + + const globalObject = globalThis as Record; + if (globalObject.__nativeScriptIdleAwareWorkletsFrameLoop === true) { + return true; + } + + const nativeRequestAnimationFrame = + globalObject.__nativeRequestAnimationFrame; + const callMicrotasks = globalObject.__callMicrotasks; + + if ( + typeof nativeRequestAnimationFrame !== "function" || + typeof callMicrotasks !== "function" + ) { + return false; + } + + globalObject.__nativeScriptIdleAwareWorkletsFrameLoop = true; + globalObject.__nativeScriptNativeRequestAnimationFrame = + nativeRequestAnimationFrame; + + let queuedCallbacks: Array<(timestamp: number) => void> = []; + let queuedCallbacksBegin = 0; + let queuedCallbacksEnd = 0; + let flushedCallbacks = queuedCallbacks; + let flushedCallbacksBegin = 0; + let flushedCallbacksEnd = 0; + let queuedFinalizers: Array<() => void> = []; + let nativeFlushScheduled = false; + + const NSTimerClass = nativeApiClass("NSTimer"); + const NSRunLoopClass = nativeApiClass("NSRunLoop"); + if ( + NSTimerClass == null || + NSRunLoopClass == null || + NSRunLoopClass.mainRunLoop == null + ) { + throw new Error("NativeScript Worklets timers require NSTimer/NSRunLoop"); + } + + type NativeTimer = { invalidate?: () => void }; + const nativeTimers = new Map(); + let nextNativeTimerHandle = 1; + + function runtimeTimerInvoker any>( + callback: T, + ): T { + const wrapped = function nativeScriptWorkletTimerCallback( + this: unknown, + ...args: unknown[] + ) { + return callback.apply(this, args); + } as T; + Object.defineProperties(wrapped, { + __nativeScriptCallbackThread: { + configurable: false, + enumerable: false, + writable: false, + value: "runtime", + }, + __nativeScriptWrappedCallback: { + configurable: false, + enumerable: false, + writable: false, + value: callback, + }, + }); + return wrapped; + } + + function normalizeTimerDelay(delay: unknown): number { + const numericDelay = isFiniteNumber(delay) ? delay : 0; + return Math.max(0.001, numericDelay / 1000); + } + + function scheduleNativeTimer( + callback: (...args: unknown[]) => void, + delay: unknown, + repeats: boolean, + args: unknown[], + ): number { + if (typeof callback !== "function") { + throw new TypeError("NativeScript Worklets timer expects a callback"); + } + + const handle = nextNativeTimerHandle++; + const fireTimer = runtimeTimerInvoker((timer: NativeTimer) => { + if (!nativeTimers.has(handle)) { + return; + } + if (!repeats) { + nativeTimers.delete(handle); + } + callback(...args); + callMicrotasks(); + if (!repeats) { + timer?.invalidate?.(); + } + }); + + const interval = normalizeTimerDelay(delay); + const timer = + typeof NSTimerClass.timerWithTimeIntervalRepeatsBlock === "function" + ? NSTimerClass.timerWithTimeIntervalRepeatsBlock( + interval, + repeats, + fireTimer, + ) + : NSTimerClass.scheduledTimerWithTimeIntervalRepeatsBlock( + interval, + repeats, + fireTimer, + ); + + nativeTimers.set(handle, timer); + if (typeof NSTimerClass.timerWithTimeIntervalRepeatsBlock === "function") { + NSRunLoopClass.mainRunLoop.addTimerForMode( + timer, + "kCFRunLoopCommonModes", + ); + } + return handle; + } + + function clearNativeTimer(handle: unknown) { + if (typeof handle !== "number") { + return; + } + const timer = nativeTimers.get(handle); + nativeTimers.delete(handle); + timer?.invalidate?.(); + } + + function hasPendingFrameWork() { + return queuedCallbacks.length > 0 || queuedFinalizers.length > 0; + } + + function executeQueue(timestamp: number) { + flushedCallbacks = queuedCallbacks; + queuedCallbacks = []; + + flushedCallbacksBegin = queuedCallbacksBegin; + flushedCallbacksEnd = queuedCallbacksEnd; + queuedCallbacksBegin = queuedCallbacksEnd; + + for (const callback of flushedCallbacks) { + callback(timestamp); + } + + flushedCallbacksBegin = flushedCallbacksEnd; + callMicrotasks(); + + const finalizers = queuedFinalizers; + queuedFinalizers = []; + for (const finalizer of finalizers) { + finalizer(); + } + } + + function flushQueue(timestamp: number) { + globalObject.__frameTimestamp = timestamp; + executeQueue(timestamp); + globalObject.__frameTimestamp = undefined; + } + + function nativeFlushQueue(timestamp: number) { + nativeFlushScheduled = false; + flushQueue(timestamp); + if (hasPendingFrameWork()) { + scheduleNativeFlush(); + } + } + + function scheduleNativeFlush() { + if (nativeFlushScheduled) { + return; + } + nativeFlushScheduled = true; + nativeRequestAnimationFrame(nativeFlushQueue); + } + + globalObject.requestAnimationFrame = ( + callback: (timestamp: number) => void, + ): number => { + const handle = queuedCallbacksEnd; + queuedCallbacksEnd += 1; + queuedCallbacks.push(callback); + scheduleNativeFlush(); + return handle; + }; + + globalObject.cancelAnimationFrame = (handle: number) => { + if (handle < flushedCallbacksBegin || handle >= queuedCallbacksEnd) { + return; + } + + if (handle < flushedCallbacksEnd) { + flushedCallbacks[handle - flushedCallbacksBegin] = () => undefined; + } else { + queuedCallbacks[handle - queuedCallbacksBegin] = () => undefined; + } + }; + + globalObject.requestAnimationFrameFinalizer = (callback: () => void) => { + queuedFinalizers.push(callback); + scheduleNativeFlush(); + }; + + globalObject.setTimeout = ( + callback: (...args: unknown[]) => void, + delay?: unknown, + ...args: unknown[] + ) => scheduleNativeTimer(callback, delay, false, args); + globalObject.clearTimeout = clearNativeTimer; + globalObject.setInterval = ( + callback: (...args: unknown[]) => void, + delay?: unknown, + ...args: unknown[] + ) => scheduleNativeTimer(callback, delay, true, args); + globalObject.clearInterval = clearNativeTimer; + + globalObject.__flushAnimationFrame = (eventTimestamp: number) => { + nativeFlushScheduled = false; + flushQueue(eventTimestamp); + if (hasPendingFrameWork()) { + scheduleNativeFlush(); + } + }; + + // Stop react-native-worklets' startup frame pump. The replacements above + // schedule the native display link only when worklet callbacks are pending. + globalObject.__nativeRequestAnimationFrame = () => undefined; + + return true; +} + +function ensureWorkletsInstalled(metadataPath = ""): NativeScriptWorklets { + if (workletsAdapter) { + return workletsAdapter; + } + installWorklets(requireReactNativeWorklets(), metadataPath); + return workletsAdapter as NativeScriptWorklets; +} + +function installWorklets( + worklets: NativeScriptWorklets = requireReactNativeWorklets(), + metadataPath = "", +): boolean { + if (!NativeScriptNativeApi.isInstalled()) { + const installed = NativeScriptNativeApi.install(metadataPath); + if (!installed) { + throw new Error( + "NativeScript Native API JSI host object was not installed", + ); + } + installInteropConstructors(); + installInlineFunctions(); + } + + const validWorklets = validateWorkletsModule(worklets); + const holder = validWorklets.getUIRuntimeHolder(); + if (holder == null || typeof holder !== "object") { + throw workletsSetupError( + "NativeScript.runOnUI could not resolve a Worklets UI runtime", + ); + } + const installRuntime = NativeScriptNativeApi.installWorkletRuntime; + if (typeof installRuntime !== "function") { + throw workletsSetupError( + "NativeScript Native API was built without RNWorklets runtime support", + ); + } + const installed = installRuntime(holder, metadataPath); + if (!installed) { + throw workletsSetupError( + "NativeScript Native API could not install into the Worklets UI runtime", + ); + } + validWorklets + .runOnUIAsync(installIdleAwareWorkletsFrameLoop) + .catch(() => undefined); + validWorklets + .runOnUIAsync(installUIKitNativeMountBridge) + .catch(() => undefined); + workletsAdapter = validWorklets; + return true; +} + +/** + * Schedule a `"worklet"` callback on the Worklets UI runtime and resolve with its + * result — the way to touch UIKit from React code. Throws if `callback` was not + * transformed into a worklet (the RN JS runtime is not a valid UI-thread shim). + */ +export function runOnUI( + callback: (...args: Args) => ReturnValue | Promise, + ...args: Args +): Promise { + if (typeof callback !== "function") { + throw new TypeError("NativeScript.runOnUI expects a Worklets callback"); + } + + ensureNativeScriptInstalled(); + const worklets = ensureWorkletsInstalled(); + if (worklets.isWorkletFunction(callback) !== true) { + throw workletsSetupError( + "NativeScript.runOnUI requires a worklet callback", + ); + } + return worklets.runOnUIAsync(callback, ...args); +} + +function runOnUISync( + callback: (...args: Args) => ReturnValue, + ...args: Args +): ReturnValue { + if (typeof callback !== "function") { + throw new TypeError("NativeScript.runOnUISync expects a Worklets callback"); + } + + ensureNativeScriptInstalled(); + const worklets = ensureWorkletsInstalled(); + if (worklets.isWorkletFunction(callback) !== true) { + throw workletsSetupError( + "NativeScript.runOnUISync requires a worklet callback", + ); + } + return worklets.runOnUISync(callback, ...args); +} + +function registerUIRuntimeGlobalOnUI( + name: string, + value: unknown, + force = true, +): boolean { + "worklet"; + + if (!name) { + return false; + } + + const globalObject = globalThis as Record; + if (!force && Object.prototype.hasOwnProperty.call(globalObject, name)) { + return false; + } + + globalObject[name] = value; + return true; +} + +/** + * Install a shared value as a global on the UI runtime so multiple worklets can + * reach it without re-capturing it. Resolves `true` once installed. Prefer plain + * closure capture for one-off values. + */ +export function registerUIRuntimeGlobal( + name: string, + value: T, + force = true, +): Promise { + return runOnUI(registerUIRuntimeGlobalOnUI, name, value, force); +} + +/** + * From inside a worklet, defer a `() => void` onto the platform main dispatch + * queue. Returns `false` if the native scheduler is not installed. + */ +export function dispatchAsyncOnMainQueue(callback: () => void): boolean { + "worklet"; + + if (typeof callback !== "function") { + throw new TypeError( + "NativeScript.dispatchAsyncOnMainQueue expects a callback", + ); + } + + const scheduler = (globalThis as Record) + .__nativeScriptDispatchAsyncOnMainQueue; + if (typeof scheduler !== "function") { + return false; + } + return scheduler(callback) === true; +} + +function callbackInvoker( + thread: NativeScriptCallbackThread, + callback: T, +): NativeScriptInvokedCallback { + "worklet"; + + if (typeof callback !== "function") { + throw new TypeError("NativeScript callback invoker expects a function"); + } + + const existingPolicy = (callback as Record)[ + nativeApiCallbackThreadKey + ]; + if (existingPolicy === thread) { + return callback as NativeScriptInvokedCallback; + } + + const wrapped = function nativeScriptInvokedCallback( + this: unknown, + ...args: unknown[] + ) { + return callback.apply(this, args); + } as NativeScriptInvokedCallback; + + for (const key of [ + ...Object.getOwnPropertyNames(callback), + ...Object.getOwnPropertySymbols(callback), + ]) { + if (nativeCallbackMetadataSkipKeys.has(key)) { + continue; + } + + const descriptor = Object.getOwnPropertyDescriptor(callback, key); + if (!descriptor) { + continue; + } + + try { + Object.defineProperty(wrapped, key, descriptor); + } catch { + // Metadata preservation is best-effort for runtimes with fixed function + // internals; the callback policy markers below are still applied. + } + } + + Object.defineProperties(wrapped, { + [nativeApiCallbackThreadKey]: { + configurable: false, + enumerable: false, + writable: false, + value: thread, + }, + [nativeApiWrappedCallbackKey]: { + configurable: false, + enumerable: false, + writable: false, + value: callback, + }, + }); + return wrapped; +} + +/** + * Tag a callback with a per-method thread/return policy the interop bridge + * honors when invoking it. Hazard: the marker is a non-enumerable property — + * keep and pass the returned (same) reference. + */ +export function nativeMethodPolicy( + callback: T, + policy: NativeScriptMethodCallbackPolicy, +): NativeScriptMethodPolicyCallback { + "worklet"; + + if (typeof callback !== "function") { + throw new TypeError("NativeScript.nativeMethodPolicy expects a function"); + } + + Object.defineProperty(callback, nativeApiMethodPolicyKey, { + configurable: false, + enumerable: false, + writable: false, + value: policy, + }); + return callback as NativeScriptMethodPolicyCallback; +} + +function jsInvoker( + callback: T, +): NativeScriptInvokedCallback { + "worklet"; + + return callbackInvoker("js", callback); +} + +function runtimeInvoker( + callback: T, +): NativeScriptInvokedCallback { + "worklet"; + + return callbackInvoker("runtime", callback); +} + +function nativeScriptCallbackThread( + callback: AnyFunction, +): NativeScriptCallbackThread | undefined { + "worklet"; + + const thread = (callback as Record)[ + nativeApiCallbackThreadKey + ]; + return thread === "js" || thread === "runtime" ? thread : undefined; +} + +function nativeScriptWrappedCallback(callback: AnyFunction): AnyFunction { + "worklet"; + + const wrapped = (callback as Record)[ + nativeApiWrappedCallbackKey + ]; + return typeof wrapped === "function" ? (wrapped as AnyFunction) : callback; +} + +function invokeNativeScriptCallback( + callback: AnyFunction, + args: unknown[], + isDisposed?: () => boolean, +): void { + "worklet"; + + if (nativeScriptCallbackThread(callback) !== "js") { + callback(...args); + return; + } + + const handler = nativeScriptWrappedCallback(callback); + const workletsProxy = (globalThis as Record) + .__workletsModuleProxy; + const serializer = (globalThis as Record).__serializer; + + if ( + workletsProxy && + typeof workletsProxy.scheduleOnRN === "function" && + typeof serializer === "function" + ) { + workletsProxy.scheduleOnRN(handler, serializer(args)); + return; + } + + setTimeout(() => { + if (!isDisposed?.()) { + handler(...args); + } + }, 0); +} + +function eventBridge( + callback: T, + thread: NativeScriptCallbackThread | "caller" = "js", +): T | NativeScriptInvokedCallback { + "worklet"; + + if (thread === "js") { + return jsInvoker(callback); + } + if (thread === "runtime") { + return runtimeInvoker(callback); + } + return callback; +} + +export type NativeActionTarget = { + action: string; + callbackKey: string; + dispose(): void; + invoke(sender?: unknown): boolean; + target: unknown; +}; + +export type NativeUIAction = { + action: unknown; + actionTarget: NativeActionTarget; + dispose(): void; + invoke(sender?: unknown): boolean; +}; + +function canCreateNativeActionTarget(): boolean { + "worklet"; + + const nsObject = nativeApiClass("NSObject"); + return !!nsObject && typeof nsObject.extend === "function"; +} + +function createNativeClassInstance(nativeClass: any): T { + "worklet"; + + if ( + !nativeClass || + (typeof nativeClass !== "object" && typeof nativeClass !== "function") + ) { + throw new Error("Native class cannot be initialized"); + } + if (typeof nativeClass.new === "function") { + return nativeClass.new() as T; + } + if (typeof nativeClass.alloc !== "function") { + throw new Error("Native class cannot be allocated"); + } + + const instance = nativeClass.alloc(); + if (instance && typeof instance.init === "function") { + return instance.init() as T; + } + return instance as T; +} +function objcInteropTypes(): any { + "worklet"; + + return (globalThis as Record).interop?.types; +} +/** + * Dynamic native class lookup by name; `null` if unavailable. Hazard: class + * globals are lazy — avoid forcing member enumeration (Object.keys, prototype + * introspection) on large classes in hot paths. + */ +export function getClass(name: string): T | null { + "worklet"; + + if (!name) { + return null; + } + const nativeClass = nativeApiClass(name); + if (nativeClass == null) { + return null; + } + const wrapped = wrapNativeClass(nativeClass); + return wrapped == null ? null : (wrapped as T); +} +function requireNSObject(): any { + "worklet"; + + const nsObject = getClass("NSObject"); + if (!nsObject || typeof nsObject.extend !== "function") { + throw new Error( + "NSObject.extend is not available from NativeScript Native API", + ); + } + return nsObject; +} +function runtimeGlobalMap(name: string): Map { + "worklet"; + + const globalObject = globalThis as Record; + const existing = globalObject[name]; + if (existing instanceof Map) { + return existing as Map; + } + + const map = new Map(); + Object.defineProperty(globalThis, name, { + configurable: true, + enumerable: false, + writable: false, + value: map, + }); + return map; +} +const targetActionClassGlobalName = "__nativeScriptUIKitTargetActionClass"; +const observerClassGlobalName = "__nativeScriptUIKitObserverClass"; +const targetActionCallbacksGlobalName = + "__nativeScriptUIKitTargetActionCallbacks"; +const observerCallbacksGlobalName = "__nativeScriptUIKitObserverCallbacks"; +const invokeNativeActionTargetGlobalName = + "__nativeScriptInvokeNativeActionTarget"; + +function targetActionCallbacksForRuntime(): Map< + string, + (sender: unknown) => void +> { + "worklet"; + + return runtimeGlobalMap<(sender: unknown) => void>( + targetActionCallbacksGlobalName, + ); +} +function nativeCallbackKey(value: unknown): string { + "worklet"; + + const handleof = (globalThis as Record).interop?.handleof; + if (value != null && typeof handleof === "function") { + const handle = handleof(value); + if (handle != null) { + if (typeof handle.toHexString === "function") { + return handle.toHexString(); + } + return String(handle); } + } + return String(value); +} +function getTargetActionClass(): any { + "worklet"; - if (handle < flushedCallbacksEnd) { - flushedCallbacks[handle - flushedCallbacksBegin] = () => undefined; - } else { - queuedCallbacks[handle - queuedCallbacksBegin] = () => undefined; + const globalObject = globalThis as Record; + const cached = globalObject[targetActionClassGlobalName]; + if (cached) { + return cached; + } + const types = objcInteropTypes(); + const NSObject = requireNSObject(); + const targetActionClass = NSObject.extend( + { + nativeScriptHandleAction(sender: unknown) { + const callback = targetActionCallbacksForRuntime().get( + nativeCallbackKey(this), + ); + if (typeof callback === "function") { + callback(sender); + } + }, + }, + { + exposedMethods: { + "nativeScriptHandleAction:": { + returns: types?.void, + params: [NSObject], + }, + }, + }, + ); + Object.defineProperty(globalThis, targetActionClassGlobalName, { + configurable: true, + enumerable: false, + writable: false, + value: targetActionClass, + }); + return targetActionClass; +} +function createNativeActionTarget( + callback: AnyFunction, +): NativeActionTarget { + "worklet"; + + if (typeof callback !== "function") { + throw new Error("createNativeActionTarget expects a callback"); + } + if (!canCreateNativeActionTarget()) { + throw new Error( + "createNativeActionTarget requires Objective-C interop globals on the current runtime", + ); + } + + const target = createNativeClassInstance(getTargetActionClass()); + const targetKey = nativeCallbackKey(target); + let disposed = false; + const invoke = (sender?: unknown) => { + if (disposed) { + return false; } + + invokeNativeScriptCallback(callback, [sender], () => disposed); + return true; }; - globalObject.requestAnimationFrameFinalizer = (callback: () => void) => { - queuedFinalizers.push(callback); - scheduleNativeFlush(); + targetActionCallbacksForRuntime().set(targetKey, (sender) => { + invoke(sender); + }); + + return { + action: "nativeScriptHandleAction:", + callbackKey: targetKey, + dispose() { + disposed = true; + targetActionCallbacksForRuntime().delete(targetKey); + }, + invoke, + target, }; +} - globalObject.setTimeout = ( - callback: (...args: unknown[]) => void, - delay?: unknown, - ...args: unknown[] - ) => scheduleNativeTimer(callback, delay, false, args); - globalObject.clearTimeout = clearNativeTimer; - globalObject.setInterval = ( - callback: (...args: unknown[]) => void, - delay?: unknown, - ...args: unknown[] - ) => scheduleNativeTimer(callback, delay, true, args); - globalObject.clearInterval = clearNativeTimer; +function invokeNativeActionTarget( + actionTarget: + | Pick + | null + | undefined, + sender?: unknown, +): boolean { + "worklet"; - globalObject.__flushAnimationFrame = (eventTimestamp: number) => { - nativeFlushScheduled = false; - flushQueue(eventTimestamp); - if (hasPendingFrameWork()) { - scheduleNativeFlush(); - } + if (typeof actionTarget?.invoke === "function") { + return actionTarget.invoke(sender) === true; + } + + const invoke = (globalThis as Record) + .__nativeScriptInvokeNativeActionTarget; + return typeof invoke === "function" + ? invoke(actionTarget, sender) === true + : false; +} + +function canCreateNativeUIAction(): boolean { + "worklet"; + + const UIAction = nativeApiClass("UIAction"); + const InteropBlock = (globalThis as Record).interop?.Block; + return ( + canCreateNativeActionTarget() && + !!UIAction && + typeof InteropBlock === "function" && + (typeof UIAction.actionWithTitleImageIdentifierHandler === "function" || + typeof UIAction.alloc === "function") + ); +} + +function createNativeUIAction( + callback: AnyFunction, + options: { + discoverabilityTitle?: string; + identifier?: string; + image?: unknown; + title?: string; + } = {}, +): NativeUIAction { + "worklet"; + + if (typeof callback !== "function") { + throw new Error("createNativeUIAction expects a callback"); + } + if (!canCreateNativeUIAction()) { + throw new Error( + "createNativeUIAction requires UIAction, interop.Block, and Objective-C target/action support", + ); + } + + const UIAction = nativeApiClass("UIAction"); + const InteropBlock = (globalThis as Record).interop.Block; + const actionTarget = createNativeActionTarget(callback); + const block = InteropBlock( + "v@?@", + eventBridge((sender: unknown) => { + "worklet"; + invokeNativeActionTarget(actionTarget, sender); + }, "runtime"), + ); + const title = options.title ?? ""; + const image = options.image ?? null; + const identifier = options.identifier ?? null; + const allocatedAction = + typeof UIAction.alloc === "function" ? UIAction.alloc() : null; + const action = + allocatedAction && + typeof allocatedAction.initWithTitleImageIdentifierHandler === "function" + ? allocatedAction.initWithTitleImageIdentifierHandler( + title, + image, + identifier, + block, + ) + : UIAction.actionWithTitleImageIdentifierHandler( + title, + image, + identifier, + block, + ); + let disposed = false; + + if (typeof options.discoverabilityTitle === "string") { + action.discoverabilityTitle = options.discoverabilityTitle; + } + + const retainer = defaultNativeRetainerForRuntime(); + retainer.retain(actionTarget.target); + retainer.retain(block); + retainer.retain(action); + setAssociatedNativeObject( + action, + "__nativeScriptUIActionTarget", + actionTarget.target, + "retainNonatomic", + ); + setAssociatedNativeObject( + action, + "__nativeScriptUIActionBlock", + block, + "retainNonatomic", + ); + + return { + action, + actionTarget, + dispose() { + if (disposed) { + return; + } + disposed = true; + actionTarget.dispose(); + setAssociatedNativeObject( + action, + "__nativeScriptUIActionTarget", + null, + "assign", + ); + setAssociatedNativeObject( + action, + "__nativeScriptUIActionBlock", + null, + "assign", + ); + retainer.release(action); + retainer.release(block); + retainer.release(actionTarget.target); + }, + invoke(sender?: unknown) { + if (disposed) { + return false; + } + return invokeNativeActionTarget(actionTarget, sender); + }, + }; +} + +/** + * Re-run a host's opt-in `refresh` hook when UIKit moved the hosted view without + * a React prop change. No-op (returns `false`) for non-hosted views. + */ +export function refreshUIKitHostView(view: unknown): boolean { + "worklet"; + + const refresh = (globalThis as Record) + .__nativeScriptRefreshUIKitHostView; + if (typeof refresh !== "function") { + return false; + } + + const viewHandle = tryNativeHandleForUIKitView(view); + return ( + typeof viewHandle === "string" && + viewHandle.length > 0 && + refresh(viewHandle) === true + ); +} + +/** + * Force a hosted view's display to flush now (an explicit sibling of + * {@link refreshUIKitHostView} for the reveal hot path). Returns `false` for + * non-hosted views or when the native flush entry point is unavailable. + */ +export function flushUIKitHostView(view: unknown): boolean { + "worklet"; + + const flush = (globalThis as Record) + .__nativeScriptFlushUIKitHostView; + if (typeof flush !== "function") { + return false; + } + + const viewHandle = tryNativeHandleForUIKitView(view); + return ( + typeof viewHandle === "string" && + viewHandle.length > 0 && + flush(viewHandle) === true + ); +} + +/** + * Post a UIKit accessibility layout-changed notification for a reattached host so + * assistive tech re-reads its element tree. Returns `false` if not applicable. + */ +export function notifyUIKitAccessibilityLayoutChanged(view: unknown): boolean { + "worklet"; + + const notify = (globalThis as Record) + .__nativeScriptNotifyUIKitAccessibilityLayoutChanged; + if (typeof notify !== "function") { + return false; + } + + const viewHandle = tryNativeHandleForUIKitView(view); + return ( + typeof viewHandle === "string" && + viewHandle.length > 0 && + notify(viewHandle) === true + ); +} + +function normalizeReactNativeFabricViewLayoutTraits( + value: unknown, +): ReactNativeFabricViewLayoutTraits | null { + "worklet"; + + if (value == null || typeof value !== "object") { + return null; + } + + const traits = value as Record; + const numberOrNull = (nextValue: unknown): number | null => { + "worklet"; + + return isFiniteNumber(nextValue) ? nextValue : null; + }; + const optionalNumber = (nextValue: unknown): number | undefined => { + "worklet"; + + return isFiniteNumber(nextValue) ? nextValue : undefined; }; - // Stop react-native-worklets' startup frame pump. The replacements above - // schedule the native display link only when worklet callbacks are pending. - globalObject.__nativeRequestAnimationFrame = () => undefined; + return { + isFabricComponentView: traits.isFabricComponentView === true, + hasYogaStyle: traits.hasYogaStyle === true, + hasLayoutMetrics: traits.hasLayoutMetrics === true, + flex: numberOrNull(traits.flex), + flexGrow: numberOrNull(traits.flexGrow), + flexShrink: numberOrNull(traits.flexShrink), + frameX: optionalNumber(traits.frameX), + frameY: optionalNumber(traits.frameY), + frameWidth: optionalNumber(traits.frameWidth), + frameHeight: optionalNumber(traits.frameHeight), + layoutMetricsFrameX: optionalNumber(traits.layoutMetricsFrameX), + layoutMetricsFrameY: optionalNumber(traits.layoutMetricsFrameY), + layoutMetricsFrameWidth: optionalNumber(traits.layoutMetricsFrameWidth), + layoutMetricsFrameHeight: optionalNumber(traits.layoutMetricsFrameHeight), + layoutMetricsContentFrameX: optionalNumber( + traits.layoutMetricsContentFrameX, + ), + layoutMetricsContentFrameY: optionalNumber( + traits.layoutMetricsContentFrameY, + ), + layoutMetricsContentFrameWidth: optionalNumber( + traits.layoutMetricsContentFrameWidth, + ), + layoutMetricsContentFrameHeight: optionalNumber( + traits.layoutMetricsContentFrameHeight, + ), + }; +} + +/** + * Fabric layout metrics/traits for a view addressed by its native handle. + */ +export function reactNativeFabricViewLayoutTraitsForHandle( + viewHandle: string, +): ReactNativeFabricViewLayoutTraits | null { + "worklet"; + + const readTraits = (globalThis as Record) + .__nativeScriptReactFabricViewLayoutTraits; + if (typeof readTraits !== "function" || !viewHandle) { + return null; + } + + return normalizeReactNativeFabricViewLayoutTraits(readTraits(viewHandle)); +} +/** + * Fabric layout metrics/traits (frame, content frame, `hasLayoutMetrics`) for a + * view object, or `null` if it carries none. + */ +export function reactNativeFabricViewLayoutTraits( + view: unknown, +): ReactNativeFabricViewLayoutTraits | null { + "worklet"; + + const viewHandle = nativeHandleForNSObject(view); + if (!viewHandle) { + return null; + } + + return reactNativeFabricViewLayoutTraitsForHandle(viewHandle); +} + +/** + * Resolve an RN image source to a native `UIImage`, invoking + * `callback(image, error)`. Also available as `ctx.loadImage`. Returns `false` + * if the native loader or callback is missing. + */ +export function loadImage( + source: unknown, + options: NativeScriptImageLoadOptions = {}, + callback: NativeScriptImageLoadCallback, +): boolean { + "worklet"; + + const loadReactImage = (globalThis as Record) + .__nativeScriptLoadReactImage; + if (typeof loadReactImage !== "function" || typeof callback !== "function") { + return false; + } + + return ( + loadReactImage( + source, + options.template === true, + (handle: unknown, errorMessage: unknown) => { + "worklet"; + + const interop = (globalThis as Record).interop; + const image = + typeof handle === "string" && handle.length > 0 + ? (interop?.object?.(interop.Pointer(handle)) ?? null) + : null; + const error = + typeof errorMessage === "string" && errorMessage.length > 0 + ? new Error(errorMessage) + : null; + callback(image, error); + }, + ) === true + ); +} + +function systemFrameworkPath(nameOrPath: string): string { + if (!nameOrPath) { + return ""; + } + if (nameOrPath.includes("/")) { + return nameOrPath; + } + const frameworkName = nameOrPath.endsWith(".framework") + ? nameOrPath + : `${nameOrPath}.framework`; + return `/System/Library/Frameworks/${frameworkName}`; +} + +function getProtocol(name: string): T | null { + "worklet"; + + if (!name) { + return null; + } + const api = requireNativeApiHost(); + const protocol = api.getProtocol?.(name) ?? api[name]; + return protocol == null ? null : (protocol as T); +} +/** + * Whether a native class is available on this OS/device. Hazard: simulator and + * device availability can differ for optional frameworks (VisionKit, PassKit, …). + */ +export function isClassAvailable(name: string): boolean { + const nativeClass = getClass>(name); + if (!nativeClass) { + return false; + } + if (typeof nativeClass.available === "boolean") { + return nativeClass.available; + } return true; } -function ensureWorkletsInstalled(metadataPath = ""): NativeScriptWorklets { - if (workletsAdapter) { - return workletsAdapter; +function frameworkBundle(nameOrPath: string): any | null { + const NSBundle = getClass("NSBundle"); + if (!NSBundle || typeof NSBundle.bundleWithPath !== "function") { + return null; } - installWorklets(requireReactNativeWorklets(), metadataPath); - return workletsAdapter as NativeScriptWorklets; + const path = systemFrameworkPath(nameOrPath); + if (!path) { + return null; + } + return NSBundle.bundleWithPath(path) ?? null; } -export function installWorklets( - worklets: NativeScriptWorklets = requireReactNativeWorklets(), - metadataPath = "", -): boolean { - if (!NativeScriptNativeApi.isInstalled()) { - const installed = NativeScriptNativeApi.install(metadataPath); - if (!installed) { - throw new Error( - "NativeScript Native API JSI host object was not installed", - ); - } - installInteropConstructors(); - installInlineFunctions(); +const frameworkSentinelClasses: Record = { + Foundation: "NSObject", + UIKit: "UIView", + QuickLook: "QLPreviewController", + VisionKit: "VNDocumentCameraViewController", + PassKit: "PKPass", +}; + +function frameworkName(nameOrPath: string): string { + const match = /([^/]+)\.framework(?:\/)?$/.exec(nameOrPath); + if (match) { + return match[1]; } + return nameOrPath.replace(/\.framework$/, ""); +} - const validWorklets = validateWorkletsModule(worklets); - const holder = validWorklets.getUIRuntimeHolder(); - if (holder == null || typeof holder !== "object") { - throw workletsSetupError( - "NativeScript.runOnUI could not resolve a Worklets UI runtime", - ); +function isFrameworkLoaded(nameOrPath: string): boolean { + const sentinelClass = frameworkSentinelClasses[frameworkName(nameOrPath)]; + if (sentinelClass && isClassAvailable(sentinelClass)) { + return true; } - const installRuntime = NativeScriptNativeApi.installWorkletRuntime; - if (typeof installRuntime !== "function") { - throw workletsSetupError( - "NativeScript Native API was built without RNWorklets runtime support", - ); + const bundle = frameworkBundle(nameOrPath); + if (!bundle) { + return false; } - const installed = installRuntime(holder, metadataPath); - if (!installed) { - throw workletsSetupError( - "NativeScript Native API could not install into the Worklets UI runtime", - ); + if (typeof bundle.loaded === "boolean") { + return bundle.loaded; } - validWorklets - .runOnUIAsync(installIdleAwareWorkletsFrameLoop) - .catch(() => undefined); - workletsAdapter = validWorklets; - return true; + if (typeof bundle.isLoaded === "function") { + return Boolean(bundle.isLoaded()); + } + return false; } -export function runOnUI( - callback: (...args: Args) => ReturnValue | Promise, - ...args: Args -): Promise { - if (typeof callback !== "function") { - throw new TypeError("NativeScript.runOnUI expects a Worklets callback"); +/** + * Load a system framework by name or `.framework` path before touching its + * classes/protocols. Returns whether it is loaded afterward. + */ +export function loadFramework(nameOrPath: string): boolean { + if (!nameOrPath) { + return false; } - - ensureNativeScriptInstalled(); - const worklets = ensureWorkletsInstalled(); - if (worklets.isWorkletFunction(callback) !== true) { - throw workletsSetupError( - "NativeScript.runOnUI requires a worklet callback", - ); + if (isFrameworkLoaded(nameOrPath)) { + return true; + } + const api = requireNativeApiHost(); + try { + if (typeof api.import === "function") { + api.import(nameOrPath); + return true; + } + } catch { + // Fall through to NSBundle below so callers get a false availability result. + } + const bundle = frameworkBundle(nameOrPath); + if (!bundle || typeof bundle.load !== "function") { + return false; + } + try { + return Boolean(bundle.load()); + } catch { + return false; } - return worklets.runOnUIAsync(callback, ...args); } -function callbackInvoker( - thread: NativeScriptCallbackThread, - callback: T, -): NativeScriptInvokedCallback { +function resolveProtocolReference( + protocolRef: NativeProtocolReference, +): unknown { "worklet"; - if (typeof callback !== "function") { - throw new TypeError("NativeScript callback invoker expects a function"); - } - - const existingPolicy = (callback as Record)[ - nativeApiCallbackThreadKey - ]; - if (existingPolicy === thread) { - return callback as NativeScriptInvokedCallback; + if (typeof protocolRef !== "string") { + return protocolRef; } + return ( + (globalThis as Record)[protocolRef] ?? + getProtocol(protocolRef) + ); +} - const wrapped = function nativeScriptInvokedCallback( - this: unknown, - ...args: unknown[] - ) { - return callback.apply(this, args); - } as NativeScriptInvokedCallback; +function wrapDelegateMethods( + methods: T, + thread: CreateDelegateOptions["thread"], +): T { + "worklet"; - for (const key of [ - ...Object.getOwnPropertyNames(callback), - ...Object.getOwnPropertySymbols(callback), - ]) { - if (nativeCallbackMetadataSkipKeys.has(key)) { - continue; - } + if (!thread || thread === "caller") { + return methods; + } - const descriptor = Object.getOwnPropertyDescriptor(callback, key); + const wrapped = Object.create(Object.getPrototypeOf(methods)); + for (const key of Reflect.ownKeys(methods)) { + const descriptor = Object.getOwnPropertyDescriptor(methods, key); if (!descriptor) { continue; } - - try { - Object.defineProperty(wrapped, key, descriptor); - } catch { - // Metadata preservation is best-effort for runtimes with fixed function - // internals; the callback policy markers below are still applied. + if ("value" in descriptor && typeof descriptor.value === "function") { + descriptor.value = eventBridge(descriptor.value, thread); } + Object.defineProperty(wrapped, key, descriptor); } - - Object.defineProperties(wrapped, { - [nativeApiCallbackThreadKey]: { - configurable: false, - enumerable: false, - writable: false, - value: thread, - }, - [nativeApiWrappedCallbackKey]: { - configurable: false, - enumerable: false, - writable: false, - value: callback, - }, - }); return wrapped; } -export function uiInvoker(_callback: T): never { - throw new Error( - 'NativeScript.uiInvoker is not supported in React Native. Use a Worklets "worklet" callback with NativeScript.runOnUI().', - ); -} - -export function jsInvoker( - callback: T, -): NativeScriptInvokedCallback { +/** + * Build and retain a protocol delegate from protocol objects or names. Hazard: + * UIKit holds delegates weakly — retain via `options.retainer`/`options.owner` + * (or `options.assignTo`), or the delegate dies with its closure. + */ +export function createDelegate( + protocols: NativeProtocolReference | NativeProtocolReference[], + methods: Partial, + options: CreateDelegateOptions = {}, +): T { "worklet"; - return callbackInvoker("js", callback); -} + const protocolList = (Array.isArray(protocols) ? protocols : [protocols]) + .map(resolveProtocolReference) + .filter(Boolean); + if (protocolList.length === 0) { + throw new Error( + "NativeScript.createDelegate requires at least one protocol", + ); + } -export function runtimeInvoker( - callback: T, -): NativeScriptInvokedCallback { - "worklet"; + const delegateClassOptions: Record = { + protocols: protocolList, + }; + if (options.name) { + delegateClassOptions.name = options.name; + } + const DelegateClass = requireNSObject().extend( + wrapDelegateMethods(methods, options.thread), + delegateClassOptions, + ); + const delegate = createNativeClassInstance(DelegateClass); + if (options.retainer) { + options.retainer.retain(delegate); + } else if (options.owner) { + options.owner.retain(delegate); + } else { + defaultNativeRetainerForRuntime().retain(delegate); + } - return callbackInvoker("runtime", callback); -} + const assignedObject = options.assignTo?.object as + Record | undefined; + const assignedProperty = options.assignTo?.property ?? "delegate"; + if (assignedObject) { + assignedObject[assignedProperty] = delegate; + } -function nativeScriptCallbackThread( - callback: AnyFunction, -): NativeScriptCallbackThread | undefined { - "worklet"; + options.owner?.dispose?.(() => { + if (assignedObject && assignedObject[assignedProperty] === delegate) { + assignedObject[assignedProperty] = null; + } + options.owner?.release?.(delegate); + options.retainer?.release(delegate); + if (!options.retainer && !options.owner) { + defaultNativeRetainerForRuntime().release(delegate); + } + }); - const thread = (callback as Record)[ - nativeApiCallbackThreadKey - ]; - return thread === "js" || thread === "runtime" ? thread : undefined; + return delegate; } -function nativeScriptWrappedCallback(callback: AnyFunction): AnyFunction { - "worklet"; +type UIKitRuntimeContext = UIKitViewContext & { + createArgument(): UIKitCreateArgument; + disposeResources(): void; + isDisposed(): boolean; + setFabricTransaction(transaction: UIKitFabricTransaction): void; + setNativeMountInfo(info: UIKitNativeMountInfo | null): void; +}; - const wrapped = (callback as Record)[ - nativeApiWrappedCallbackKey - ]; - return typeof wrapped === "function" ? (wrapped as AnyFunction) : callback; -} +type UIKitHostInstance = { + hostView: unknown; + lifecycleValue: NativeView; + childrenView?: unknown; + controller?: unknown; +}; -function invokeNativeScriptCallback( - callback: AnyFunction, - args: unknown[], - isDisposed?: () => boolean, -): void { - "worklet"; +type RegisteredUIKitHost = { + context: UIKitRuntimeContext; + dispose?: (props: Readonly) => UIKitDisposeResult; + hostInstance: UIKitHostInstance; + hasMounted?: boolean; + mounted?: (props: Readonly) => void; + nativeView: NativeView; + previousProps?: Readonly; + propsRevision?: number; + // The nativeHostPropsRevision (index.ts ~5880-5892 -- bumps only on a + // genuine serializable prop change, unlike propsRevision above which also + // bumps on function-identity-only churn) as of the last time host.update / + // commitUIKitHostFabricTransaction actually ran for this host. See Lever 2 + // in the update-layout-effect below. + updateAppliedNativeRevision?: number; + propsRef: { current: Readonly }; + refresh?: ( + props: Readonly, + previousProps: Readonly | undefined, + ) => void; + hostReady?: ( + props: Readonly, + event: UIKitHostReadyEvent, + previousProps: Readonly | undefined, + ) => void; + mountingTransactionWillMount?: ( + props: Readonly, + previousProps: Readonly | undefined, + ) => void; + mountingTransactionDidMount?: ( + props: Readonly, + previousProps: Readonly | undefined, + ) => void; + mountChild?: ( + child: UIKitFabricMountedChild, + props: Readonly, + previousProps: Readonly | undefined, + ) => void; + unmountChild?: ( + child: UIKitFabricMountedChild, + props: Readonly, + previousProps: Readonly | undefined, + ) => void; + transactionCommitted?: ( + props: Readonly, + previousProps: Readonly | undefined, + ) => void; + update?: ( + props: Readonly, + previousProps: Readonly | undefined, + ) => void; +}; - if (nativeScriptCallbackThread(callback) !== "js") { - callback(...args); - return; - } +type PendingUIKitHost = { + debugName: string; + mountHost: () => RegisteredUIKitHost; + nativeMountInfoRef: { current: UIKitNativeMountInfo | null }; + propsRevision?: number; + propsRef: { current: Readonly }; + requiresNativeMountInfo?: boolean; +}; - const handler = nativeScriptWrappedCallback(callback); - const workletsProxy = (globalThis as Record) - .__workletsModuleProxy; - const serializer = (globalThis as Record).__serializer; +type PendingNativeUIKitHostCreateRequest = { + nativeMountInfoJson?: string; + propsJson?: string; + shouldRunMounted: boolean; +}; - if ( - workletsProxy && - typeof workletsProxy.scheduleOnRN === "function" && - typeof serializer === "function" - ) { - workletsProxy.scheduleOnRN(handler, serializer(args)); - return; - } +type UIKitHostHandles = { + nativeViewHandle?: string; + childrenViewHandle?: string; + controllerHandle?: string; +}; - setTimeout(() => { - if (!isDisposed?.()) { - handler(...args); - } - }, 0); -} +type UIKitAdapterDefinition< + Props extends object, + NativeView, +> = UIKitViewDefinition & { + resolveHostInstance?: (created: NativeView) => UIKitHostInstance; +}; -export function eventBridge( - callback: T, - thread: NativeScriptCallbackThread | "caller" = "js", -): T | NativeScriptInvokedCallback { - "worklet"; +const uikitHostRegistryGlobalName = "__nativeScriptUIKitHostRegistry"; +const pendingUIKitHostRegistryGlobalName = + "__nativeScriptPendingUIKitHostRegistry"; +const nativeUIKitHostCreateRequestRegistryGlobalName = + "__nativeScriptPendingUIKitHostCreateRequests"; +const createUIKitHostFromNativeGlobalName = + "__nativeScriptCreateUIKitHostFromNative"; +const runUIKitHostLifecycleFromNativeGlobalName = + "__nativeScriptRunUIKitHostLifecycleFromNative"; +const refreshingUIKitHostsGlobalName = "__nativeScriptRefreshingUIKitHosts"; +let nextUIKitHostId = 1; - if (thread === "js") { - return jsInvoker(callback); - } - if (thread === "runtime") { - return runtimeInvoker(callback); - } - return callback; +function createUIKitHostId(debugName: string): string { + return `${debugName}:${nextUIKitHostId++}`; } -export const createEventBridge = eventBridge; - -export function isMainThread(): boolean { +function traceUIKitHostNativeBridgeEvent(label: string, details = ""): void { "worklet"; - const NSThread = (globalThis as Record).NSThread; - return NSThread?.isMainThread === true; -} - -export function assertUIKitThread( - message = "UIKit native APIs must be called through NativeScript.runOnUI", -): void { - "worklet"; + const globalObject = globalThis as Record; + if (globalObject.__nativeScriptUIKitHostTraceEvents !== true) { + return; + } - if (!isMainThread()) { - throw new Error(message); + const message = `[NativeScript UIKitHost] ${label}${ + details ? ` ${details}` : "" + }`; + if (typeof globalObject.TNSLog === "function") { + globalObject.TNSLog(message); + } + if (typeof console !== "undefined" && typeof console.warn === "function") { + console.warn(message); } } -export function refreshUIKitHostView(view: unknown): boolean { +function uikitHostRegistry(): Map> { "worklet"; - const refresh = (globalThis as Record) - .__nativeScriptRefreshUIKitHostView; - if (typeof refresh !== "function") { - return false; + const globalObject = globalThis as Record; + const existing = globalObject[uikitHostRegistryGlobalName]; + if (existing instanceof Map) { + return existing as Map>; } - return refresh(nativeHandleForUIKitView(view)) === true; + const registry = new Map>(); + Object.defineProperty(globalThis, uikitHostRegistryGlobalName, { + configurable: true, + enumerable: false, + writable: false, + value: registry, + }); + return registry; } -export function refreshUIKitHostViewHandle(viewHandle: string): boolean { +function pendingUIKitHostRegistry(): Map< + string, + PendingUIKitHost +> { "worklet"; - const refresh = (globalThis as Record) - .__nativeScriptRefreshUIKitHostView; - if (typeof refresh !== "function") { - return false; + const globalObject = globalThis as Record; + const existing = globalObject[pendingUIKitHostRegistryGlobalName]; + if (existing instanceof Map) { + return existing as Map>; } - return refresh(viewHandle) === true; + const registry = new Map>(); + Object.defineProperty(globalThis, pendingUIKitHostRegistryGlobalName, { + configurable: true, + enumerable: false, + writable: false, + value: registry, + }); + return registry; } -export function loadImage( - source: unknown, - options: NativeScriptImageLoadOptions = {}, - callback: NativeScriptImageLoadCallback, -): boolean { +function pendingNativeUIKitHostCreateRequestRegistry(): Map< + string, + PendingNativeUIKitHostCreateRequest +> { "worklet"; - const loadReactImage = (globalThis as Record) - .__nativeScriptLoadReactImage; - if (typeof loadReactImage !== "function" || typeof callback !== "function") { - return false; + const globalObject = globalThis as Record; + const existing = globalObject[nativeUIKitHostCreateRequestRegistryGlobalName]; + if (existing instanceof Map) { + return existing as Map; } - return ( - loadReactImage( - source, - options.template === true, - (handle: unknown, errorMessage: unknown) => { - "worklet"; - - const interop = (globalThis as Record).interop; - const image = - typeof handle === "string" && handle.length > 0 - ? interop?.object?.(interop.Pointer(handle)) ?? null - : null; - const error = - typeof errorMessage === "string" && errorMessage.length > 0 - ? new Error(errorMessage) - : null; - callback(image, error); - }, - ) === true + const registry = new Map(); + Object.defineProperty( + globalThis, + nativeUIKitHostCreateRequestRegistryGlobalName, + { + configurable: true, + enumerable: false, + writable: false, + value: registry, + }, ); + return registry; } -export function warnIfNotUIKitThread( - message = "UIKit native APIs should be mutated through NativeScript.runOnUI", -): boolean { +function refreshingUIKitHostSet(): Set { "worklet"; - if (isMainThread()) { - return false; - } - if (typeof console !== "undefined" && typeof console.warn === "function") { - console.warn(message); + const globalObject = globalThis as Record; + const existing = globalObject[refreshingUIKitHostsGlobalName]; + if (existing instanceof Set) { + return existing as Set; } - return true; -} -function systemFrameworkPath(nameOrPath: string): string { - if (!nameOrPath) { - return ""; - } - if (nameOrPath.includes("/")) { - return nameOrPath; - } - const frameworkName = nameOrPath.endsWith(".framework") - ? nameOrPath - : `${nameOrPath}.framework`; - return `/System/Library/Frameworks/${frameworkName}`; + const refreshingHosts = new Set(); + Object.defineProperty(globalThis, refreshingUIKitHostsGlobalName, { + configurable: true, + enumerable: false, + writable: false, + value: refreshingHosts, + }); + return refreshingHosts; } -export function getClass(name: string): T | null { - if (!name) { - return null; - } - const api = requireNativeApiHost(); - const nativeClass = api.getClass?.(name) ?? api[name]; - if (nativeClass == null) { - return null; - } - const wrapped = wrapNativeClass(nativeClass); - return wrapped == null ? null : (wrapped as T); -} +function uikitHostHandles( + host: RegisteredUIKitHost, +): UIKitHostHandles { + "worklet"; -export function getProtocol(name: string): T | null { - if (!name) { - return null; - } - const api = requireNativeApiHost(); - const protocol = api.getProtocol?.(name) ?? api[name]; - return protocol == null ? null : (protocol as T); + return { + nativeViewHandle: nativeHandleOrUndefined(host.hostInstance.hostView), + childrenViewHandle: nativeHandleOrUndefined(host.hostInstance.childrenView), + controllerHandle: nativeHandleForNSObject(host.hostInstance.controller), + }; } -function requireNSObject(): any { +function getRegisteredUIKitHost( + hostId: string, +): RegisteredUIKitHost { "worklet"; - const nsObject = (globalThis as Record).NSObject; - if (!nsObject || typeof nsObject.extend !== "function") { - throw new Error("NSObject.extend is not available"); + const host = uikitHostRegistry().get(hostId); + if (!host) { + throw new Error(`UIKit host ${hostId} has not been created`); } - return nsObject; + return host as RegisteredUIKitHost; } -export function isClassAvailable(name: string): boolean { - const nativeClass = getClass>(name); - if (!nativeClass) { - return false; - } - if (typeof nativeClass.available === "boolean") { - return nativeClass.available; - } - return true; +function registerUIKitHost( + hostId: string, + host: RegisteredUIKitHost, +): void { + "worklet"; + + uikitHostRegistry().set(hostId, host as RegisteredUIKitHost); } -function frameworkBundle(nameOrPath: string): any | null { - const NSBundle = getClass("NSBundle"); - if (!NSBundle || typeof NSBundle.bundleWithPath !== "function") { +function parseUIKitHostPropsJson(propsJson?: string): { + props: Record; + revision?: number; +} | null { + "worklet"; + + if (typeof propsJson !== "string" || propsJson.length === 0) { return null; } - const path = systemFrameworkPath(nameOrPath); - if (!path) { + + try { + const parsed = JSON.parse(propsJson); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + + const record = parsed as Record; + const payload = record[uikitHostPropsPayloadKey]; + if (payload && typeof payload === "object" && !Array.isArray(payload)) { + const revisionValue = record[uikitHostPropsRevisionKey]; + return { + props: payload as Record, + revision: isFiniteNumber(revisionValue) ? revisionValue : undefined, + }; + } + + return { + props: record, + }; + } catch { return null; } - return NSBundle.bundleWithPath(path) ?? null; } -const frameworkSentinelClasses: Record = { - Foundation: "NSObject", - UIKit: "UIView", - QuickLook: "QLPreviewController", - VisionKit: "VNDocumentCameraViewController", - PassKit: "PKPass", -}; +function stringRecordValue( + record: Record, + key: string, +): string { + "worklet"; -function frameworkName(nameOrPath: string): string { - const match = /([^/]+)\.framework(?:\/)?$/.exec(nameOrPath); - if (match) { - return match[1]; - } - return nameOrPath.replace(/\.framework$/, ""); + const value = record[key]; + return typeof value === "string" ? value : ""; } -export function isFrameworkLoaded(nameOrPath: string): boolean { - const sentinelClass = frameworkSentinelClasses[frameworkName(nameOrPath)]; - if (sentinelClass && isClassAvailable(sentinelClass)) { - return true; - } - const bundle = frameworkBundle(nameOrPath); - if (!bundle) { - return false; - } - if (typeof bundle.loaded === "boolean") { - return bundle.loaded; - } - if (typeof bundle.isLoaded === "function") { - return Boolean(bundle.isLoaded()); - } - return false; -} +function nativeObjectFromStringHandle(handle: string): unknown | null { + "worklet"; -export function loadFramework(nameOrPath: string): boolean { - if (!nameOrPath) { - return false; + const trimmed = handle.trim(); + if (trimmed.length === 0) { + return null; } - if (isFrameworkLoaded(nameOrPath)) { - return true; + + const address = Number(trimmed); + if (!isFiniteNumber(address) || address <= 0) { + return null; } - const api = requireNativeApiHost(); + try { - if (typeof api.import === "function") { - api.import(nameOrPath); - return true; + const interop = (globalThis as Record).interop; + if ( + !interop || + typeof interop.object !== "function" || + typeof interop.Pointer !== "function" + ) { + return null; } + + return interop.object(interop.Pointer(address)) ?? null; } catch { - // Fall through to NSBundle below so callers get a false availability result. - } - const bundle = frameworkBundle(nameOrPath); - if (!bundle || typeof bundle.load !== "function") { - return false; - } - try { - return Boolean(bundle.load()); - } catch { - return false; + return null; } } -function resolveProtocolReference( - protocolRef: NativeProtocolReference, -): unknown { +function parseUIKitNativeMountInfoJson( + nativeMountInfoJson?: string, +): UIKitNativeMountInfo | null { "worklet"; - if (typeof protocolRef !== "string") { - return protocolRef; + if ( + typeof nativeMountInfoJson !== "string" || + nativeMountInfoJson.length === 0 + ) { + return null; } - return ( - (globalThis as Record)[protocolRef] ?? - getProtocol(protocolRef) - ); -} -function wrapDelegateMethods( - methods: T, - thread: CreateDelegateOptions["thread"], -): T { - "worklet"; + try { + const parsed = JSON.parse(nativeMountInfoJson); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + traceUIKitHostNativeBridgeEvent( + "native-mount-info-parse-miss", + `reason=shape type=${typeof parsed} array=${Array.isArray(parsed) ? 1 : 0}`, + ); + return null; + } - if (!thread || thread === "caller") { - return methods; - } + const record = parsed as Record; + const fabricComponentViewHandle = stringRecordValue( + record, + "fabricComponentViewHandle", + ); + const fabricContainerViewHandle = stringRecordValue( + record, + "fabricContainerViewHandle", + ); - const wrapped = Object.create(Object.getPrototypeOf(methods)); - for (const key of Reflect.ownKeys(methods)) { - const descriptor = Object.getOwnPropertyDescriptor(methods, key); - if (!descriptor) { - continue; - } - if ("value" in descriptor && typeof descriptor.value === "function") { - descriptor.value = eventBridge(descriptor.value, thread); + if (!fabricComponentViewHandle && !fabricContainerViewHandle) { + traceUIKitHostNativeBridgeEvent( + "native-mount-info-parse-miss", + "reason=handles-missing", + ); + return null; } - Object.defineProperty(wrapped, key, descriptor); - } - return wrapped; -} -export function createDelegate( - protocols: NativeProtocolReference | NativeProtocolReference[], - methods: Partial, - options: CreateDelegateOptions = {}, -): T { - "worklet"; + traceUIKitHostNativeBridgeEvent( + "native-mount-info-parse-hit", + `componentHandle=${fabricComponentViewHandle ? 1 : 0} containerHandle=${ + fabricContainerViewHandle ? 1 : 0 + }`, + ); - const protocolList = (Array.isArray(protocols) ? protocols : [protocols]) - .map(resolveProtocolReference) - .filter(Boolean); - if (protocolList.length === 0) { - throw new Error( - "NativeScript.createDelegate requires at least one protocol", + return { + fabricComponentView: nativeObjectFromStringHandle( + fabricComponentViewHandle, + ), + fabricComponentViewHandle, + fabricContainerView: nativeObjectFromStringHandle( + fabricContainerViewHandle, + ), + fabricContainerViewHandle, + }; + } catch (error) { + traceUIKitHostNativeBridgeEvent( + "native-mount-info-parse-error", + error instanceof Error ? error.message : String(error), ); + return null; } +} - const DelegateClass = requireNSObject().extend( - wrapDelegateMethods(methods, options.thread), - { - protocols: protocolList, - name: options.name, - }, - ); - const delegate = DelegateClass.alloc().init() as T; - if (options.retainer) { - options.retainer.retain(delegate); - } else if (options.owner) { - options.owner.retain(delegate); - } else { - defaultNativeRetainer.retain(delegate); - } +function syncUIKitNativeMountInfo( + hostId: string, + nativeMountInfo: UIKitNativeMountInfo | null, +): void { + "worklet"; - const assignedObject = options.assignTo?.object as - | Record - | undefined; - const assignedProperty = options.assignTo?.property ?? "delegate"; - if (assignedObject) { - assignedObject[assignedProperty] = delegate; + if (nativeMountInfo == null) { + return; } - options.owner?.dispose?.(() => { - if (assignedObject && assignedObject[assignedProperty] === delegate) { - assignedObject[assignedProperty] = null; - } - options.owner?.release?.(delegate); - options.retainer?.release(delegate); - if (!options.retainer && !options.owner) { - defaultNativeRetainer.release(delegate); - } - }); + const pending = pendingUIKitHostRegistry().get(hostId); + if (pending) { + pending.nativeMountInfoRef.current = nativeMountInfo; + } - return delegate; + const host = uikitHostRegistry().get(hostId); + host?.context.setNativeMountInfo(nativeMountInfo); } -type UIKitRuntimeContext = UIKitViewContext & { - createArgument(): UIKitCreateArgument; - disposeResources(): void; - isDisposed(): boolean; -}; +function parseUIKitFabricTransactionJson( + transactionJson?: string, +): UIKitFabricTransaction { + "worklet"; -type UIKitHostInstance = { - hostView: unknown; - lifecycleValue: NativeView; - childrenView?: unknown; - controller?: unknown; -}; + if (typeof transactionJson !== "string" || transactionJson.length === 0) { + return { + children: [], + hasModifiedChildren: false, + hasModifiedProps: false, + mutations: [], + }; + } -type RegisteredUIKitHost = { - context: UIKitRuntimeContext; - dispose?: (props: Readonly) => UIKitDisposeResult; - hostInstance: UIKitHostInstance; - hasMounted?: boolean; - mounted?: (props: Readonly) => void; - nativeView: NativeView; - previousProps?: Readonly; - propsRef: { current: Readonly }; - update?: ( - props: Readonly, - previousProps: Readonly | undefined, - ) => void; -}; + try { + const parsed = JSON.parse(transactionJson); + const childrenValue = + parsed != null && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record).children + : undefined; + const mutationsValue = + parsed != null && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record).mutations + : undefined; + const deliveryTokenValue = + parsed != null && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record).deliveryToken + : undefined; + const deliveryToken = + typeof deliveryTokenValue === "number" && + deliveryTokenValue === deliveryTokenValue && + deliveryTokenValue !== Infinity && + deliveryTokenValue !== -Infinity + ? deliveryTokenValue + : undefined; + const children: UIKitFabricMountedChild[] = []; + if (Array.isArray(childrenValue)) { + for ( + let childIndex = 0; + childIndex < childrenValue.length; + childIndex += 1 + ) { + const child = childrenValue[childIndex]; + if ( + child == null || + typeof child !== "object" || + Array.isArray(child) + ) { + continue; + } + + const event = child as Record; + const rawIndex = event.index; + const index = + typeof rawIndex === "number" && + rawIndex === rawIndex && + rawIndex !== Infinity && + rawIndex !== -Infinity + ? rawIndex + : -1; + const ownerComponentViewHandle = stringRecordValue( + event, + "ownerComponentViewHandle", + ); + const ownerContainerViewHandle = stringRecordValue( + event, + "ownerContainerViewHandle", + ); + const ownerNativeViewHandle = stringRecordValue( + event, + "ownerNativeViewHandle", + ); + const ownerChildrenViewHandle = stringRecordValue( + event, + "ownerChildrenViewHandle", + ); + const ownerControllerHandle = stringRecordValue( + event, + "ownerControllerHandle", + ); + const componentViewHandle = stringRecordValue( + event, + "componentViewHandle", + ); + const containerViewHandle = stringRecordValue( + event, + "containerViewHandle", + ); + const nativeViewHandle = stringRecordValue(event, "nativeViewHandle"); + const childrenViewHandle = stringRecordValue( + event, + "childrenViewHandle", + ); + const controllerHandle = stringRecordValue(event, "controllerHandle"); + + children.push({ + index, + ownerComponentView: nativeObjectFromStringHandle( + ownerComponentViewHandle, + ), + ownerComponentViewHandle, + ownerContainerView: nativeObjectFromStringHandle( + ownerContainerViewHandle, + ), + ownerContainerViewHandle, + ownerNativeView: nativeObjectFromStringHandle(ownerNativeViewHandle), + ownerNativeViewHandle, + ownerChildrenView: nativeObjectFromStringHandle( + ownerChildrenViewHandle, + ), + ownerChildrenViewHandle, + ownerController: nativeObjectFromStringHandle(ownerControllerHandle), + ownerControllerHandle, + componentView: nativeObjectFromStringHandle(componentViewHandle), + componentViewHandle, + containerView: nativeObjectFromStringHandle(containerViewHandle), + containerViewHandle, + nativeView: nativeObjectFromStringHandle(nativeViewHandle), + nativeViewHandle, + childrenView: nativeObjectFromStringHandle(childrenViewHandle), + childrenViewHandle, + controller: nativeObjectFromStringHandle(controllerHandle), + controllerHandle, + }); + } + } + const mutations: UIKitFabricMutation[] = []; + if (Array.isArray(mutationsValue)) { + for ( + let mutationIndex = 0; + mutationIndex < mutationsValue.length; + mutationIndex += 1 + ) { + const mutation = mutationsValue[mutationIndex]; + if ( + mutation == null || + typeof mutation !== "object" || + Array.isArray(mutation) + ) { + continue; + } + const event = mutation as Record; + const numberOrNull = (value: unknown): number | null => { + "worklet"; + + return typeof value === "number" && + value === value && + value !== Infinity && + value !== -Infinity + ? value + : null; + }; + const index = numberOrNull(event.index); + mutations.push({ + type: stringRecordValue(event, "type"), + parentTag: numberOrNull(event.parentTag), + index: index == null ? -1 : index, + newChildTag: numberOrNull(event.newChildTag), + newChildComponentName: stringRecordValue( + event, + "newChildComponentName", + ), + oldChildTag: numberOrNull(event.oldChildTag), + oldChildComponentName: stringRecordValue( + event, + "oldChildComponentName", + ), + }); + } + } -type PendingUIKitHost = { - debugName: string; - mountHost: () => RegisteredUIKitHost; - propsRef: { current: Readonly }; -}; + return { + children, + hasModifiedChildren: + parsed != null && + typeof parsed === "object" && + (parsed as Record).hasModifiedChildren === true, + hasModifiedProps: + parsed != null && + typeof parsed === "object" && + (parsed as Record).hasModifiedProps === true, + mutations, + deliveryToken, + }; + } catch { + return { + children: [], + hasModifiedChildren: false, + hasModifiedProps: false, + mutations: [], + }; + } +} -type UIKitHostHandles = { - nativeViewHandle?: string; - childrenViewHandle?: string; - controllerHandle?: string; -}; +function parseUIKitFabricMountedChildRecord( + event: Record, +): UIKitFabricMountedChild { + "worklet"; -type UIKitAdapterDefinition< - Props extends object, - NativeView, -> = UIKitViewDefinition & { - resolveHostInstance?: (created: NativeView) => UIKitHostInstance; -}; + const stringValue = (value: unknown): string => { + "worklet"; -const uikitHostRegistryGlobalName = "__nativeScriptUIKitHostRegistry"; -const pendingUIKitHostRegistryGlobalName = - "__nativeScriptPendingUIKitHostRegistry"; -const createUIKitHostFromNativeGlobalName = - "__nativeScriptCreateUIKitHostFromNative"; -const runUIKitHostLifecycleFromNativeGlobalName = - "__nativeScriptRunUIKitHostLifecycleFromNative"; -let nextUIKitHostId = 1; + return typeof value === "string" ? value : ""; + }; + const rawIndex = event.index; + const index = + typeof rawIndex === "number" && + rawIndex === rawIndex && + rawIndex !== Infinity && + rawIndex !== -Infinity + ? rawIndex + : -1; + const ownerComponentViewHandle = stringValue(event.ownerComponentViewHandle); + const ownerContainerViewHandle = stringValue(event.ownerContainerViewHandle); + const ownerNativeViewHandle = stringValue(event.ownerNativeViewHandle); + const ownerChildrenViewHandle = stringValue(event.ownerChildrenViewHandle); + const ownerControllerHandle = stringValue(event.ownerControllerHandle); + const componentViewHandle = stringValue(event.componentViewHandle); + const containerViewHandle = stringValue(event.containerViewHandle); + const nativeViewHandle = stringValue(event.nativeViewHandle); + const childrenViewHandle = stringValue(event.childrenViewHandle); + const controllerHandle = stringValue(event.controllerHandle); -function createUIKitHostId(debugName: string): string { - return `${debugName}:${nextUIKitHostId++}`; + return { + index, + ownerComponentView: nativeObjectFromStringHandle(ownerComponentViewHandle), + ownerComponentViewHandle, + ownerContainerView: nativeObjectFromStringHandle(ownerContainerViewHandle), + ownerContainerViewHandle, + ownerNativeView: nativeObjectFromStringHandle(ownerNativeViewHandle), + ownerNativeViewHandle, + ownerChildrenView: nativeObjectFromStringHandle(ownerChildrenViewHandle), + ownerChildrenViewHandle, + ownerController: nativeObjectFromStringHandle(ownerControllerHandle), + ownerControllerHandle, + componentView: nativeObjectFromStringHandle(componentViewHandle), + componentViewHandle, + containerView: nativeObjectFromStringHandle(containerViewHandle), + containerViewHandle, + nativeView: nativeObjectFromStringHandle(nativeViewHandle), + nativeViewHandle, + childrenView: nativeObjectFromStringHandle(childrenViewHandle), + childrenViewHandle, + controller: nativeObjectFromStringHandle(controllerHandle), + controllerHandle, + }; } -function uikitHostRegistry(): Map> { +function parseUIKitFabricMountedChildJson( + transactionJson?: string, +): UIKitFabricMountedChild | null { "worklet"; - const globalObject = globalThis as Record; - const existing = globalObject[uikitHostRegistryGlobalName]; - if (existing instanceof Map) { - return existing as Map>; + if (typeof transactionJson !== "string" || transactionJson.length === 0) { + return null; } - const registry = new Map>(); - Object.defineProperty(globalThis, uikitHostRegistryGlobalName, { - configurable: true, - enumerable: false, - writable: false, - value: registry, - }); - return registry; + try { + const parsed = JSON.parse(transactionJson); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + + return parseUIKitFabricMountedChildRecord( + parsed as Record, + ); + } catch { + return null; + } } -function pendingUIKitHostRegistry(): Map< - string, - PendingUIKitHost -> { +function parseUIKitHostReadyEventJson( + eventJson?: string, +): UIKitHostReadyEvent | null { "worklet"; - const globalObject = globalThis as Record; - const existing = globalObject[pendingUIKitHostRegistryGlobalName]; - if (existing instanceof Map) { - return existing as Map>; + if (typeof eventJson !== "string" || eventJson.length === 0) { + return null; } - const registry = new Map>(); - Object.defineProperty(globalThis, pendingUIKitHostRegistryGlobalName, { - configurable: true, - enumerable: false, - writable: false, - value: registry, - }); - return registry; + try { + const parsed = JSON.parse(eventJson); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + + const event = parsed as Record; + const stringValue = (value: unknown): string => { + "worklet"; + + return typeof value === "string" ? value : ""; + }; + const numberValue = (value: unknown): number => { + "worklet"; + + return isFiniteNumber(value) ? value : 0; + }; + + return { + nativeEvent: { + hostReadyId: stringValue(event.hostReadyId), + hostId: stringValue(event.hostId), + componentViewHandle: stringValue(event.componentViewHandle), + nativeViewHandle: stringValue(event.nativeViewHandle), + childrenViewHandle: stringValue(event.childrenViewHandle), + controllerHandle: stringValue(event.controllerHandle), + hasChildren: event.hasChildren === true, + visibleDescendantCount: numberValue(event.visibleDescendantCount), + windowAttached: event.windowAttached === true, + }, + }; + } catch { + return null; + } } -function uikitHostHandles( - host: RegisteredUIKitHost, -): UIKitHostHandles { +function shouldApplyUIKitHostPropsRevision( + currentRevision: number | undefined, + nextRevision: number | undefined, +): boolean { "worklet"; - return { - nativeViewHandle: nativeHandleOrUndefined(host.hostInstance.hostView), - childrenViewHandle: nativeHandleOrUndefined(host.hostInstance.childrenView), - controllerHandle: nativeHandleForNSObject(host.hostInstance.controller), - }; + return ( + nextRevision == null || + currentRevision == null || + nextRevision > currentRevision + ); } -function getRegisteredUIKitHost( +function syncUIKitHostPropsFromNative( hostId: string, -): RegisteredUIKitHost { + propsJson?: string, +): boolean { "worklet"; - const host = uikitHostRegistry().get(hostId); - if (!host) { - throw new Error(`UIKit host ${hostId} has not been created`); + if (typeof propsJson !== "string" || propsJson.length === 0) { + return false; + } + // Every host lifecycle crossing carries the full serialized props; parsing + // multi-KB JSON per call dominated the per-crossing cost. An identical + // payload string implies an identical revision, for which the apply below + // is a no-op, so remember the last-seen string per registration. + const pending = pendingUIKitHostRegistry().get(hostId) as unknown as + | (Record & { + lastNativePropsJson?: string; + propsRef: { current: Readonly> | undefined }; + propsRevision: number | undefined; + }) + | undefined; + const host = uikitHostRegistry().get(hostId) as unknown as + | (Record & { + lastNativePropsJson?: string; + propsRef: { current: Readonly> | undefined }; + propsRevision: number | undefined; + }) + | undefined; + const pendingNeedsParse = + pending != null && pending.lastNativePropsJson !== propsJson; + const hostNeedsParse = host != null && host.lastNativePropsJson !== propsJson; + if (!pendingNeedsParse && !hostNeedsParse) { + return false; } - return host as RegisteredUIKitHost; -} -function registerUIKitHost( - hostId: string, - host: RegisteredUIKitHost, -): void { - "worklet"; + const nativePayload = parseUIKitHostPropsJson(propsJson); + if (nativePayload == null) { + return false; + } + const nativeProps = nativePayload.props; + const nativeRevision = nativePayload.revision; + let didApply = false; - uikitHostRegistry().set(hostId, host as RegisteredUIKitHost); + const mergeProps = (current: Readonly | undefined) => + mergeUIKitHostPropsFromNative(current, nativeProps) as Record< + string, + unknown + >; + + if (pendingNeedsParse) { + if ( + shouldApplyUIKitHostPropsRevision(pending!.propsRevision, nativeRevision) + ) { + pending!.propsRef.current = mergeProps(pending!.propsRef.current); + pending!.propsRevision = + (nativeRevision ?? pending!.propsRevision) as number | undefined; + didApply = true; + } + pending!.lastNativePropsJson = propsJson; + } + + if (hostNeedsParse) { + if ( + shouldApplyUIKitHostPropsRevision(host!.propsRevision, nativeRevision) + ) { + host!.propsRef.current = mergeProps(host!.propsRef.current); + host!.propsRevision = + (nativeRevision ?? host!.propsRevision) as number | undefined; + didApply = true; + } + host!.lastNativePropsJson = propsJson; + } + + return didApply; } function createRegisteredUIKitHostFromNative( hostId: string, + propsJson?: string, + shouldRunMountedOrNativeMountInfo: boolean | string = false, + maybeNativeMountInfoJson?: string, ): UIKitHostHandles | null { "worklet"; + const shouldRunMounted = + typeof shouldRunMountedOrNativeMountInfo === "boolean" + ? shouldRunMountedOrNativeMountInfo + : false; + const nativeMountInfoJson = + typeof shouldRunMountedOrNativeMountInfo === "string" + ? shouldRunMountedOrNativeMountInfo + : maybeNativeMountInfoJson; + const parsedNativeMountInfo = + parseUIKitNativeMountInfoJson(nativeMountInfoJson); + const hasNativeMountInfoJson = + typeof nativeMountInfoJson === "string" && nativeMountInfoJson.length > 0; + syncUIKitNativeMountInfo(hostId, parsedNativeMountInfo); + syncUIKitHostPropsFromNative(hostId, propsJson); + const existingHost = uikitHostRegistry().get(hostId); if (existingHost) { return uikitHostHandles(existingHost); @@ -1966,14 +4568,71 @@ function createRegisteredUIKitHostFromNative( const pending = pendingUIKitHostRegistry().get(hostId); if (!pending) { + pendingNativeUIKitHostCreateRequestRegistry().set(hostId, { + nativeMountInfoJson, + propsJson, + shouldRunMounted, + }); + traceUIKitHostNativeBridgeEvent( + "create-miss", + `host=${hostId} reason=pending-missing infoArg=${ + hasNativeMountInfoJson ? 1 : 0 + }`, + ); + return null; + } + if ( + pending.requiresNativeMountInfo === true && + pending.nativeMountInfoRef.current == null + ) { + pendingNativeUIKitHostCreateRequestRegistry().set(hostId, { + nativeMountInfoJson, + propsJson, + shouldRunMounted, + }); + traceUIKitHostNativeBridgeEvent( + "create-miss", + `host=${hostId} debug=${pending.debugName} reason=native-mount-info-missing infoArg=${ + hasNativeMountInfoJson ? 1 : 0 + } parsed=${parsedNativeMountInfo ? 1 : 0} raw=${ + hasNativeMountInfoJson ? nativeMountInfoJson : "" + }`, + ); return null; } const host = pending.mountHost(); registerUIKitHost(hostId, host); + pendingNativeUIKitHostCreateRequestRegistry().delete(hostId); + traceUIKitHostNativeBridgeEvent( + "create-hit", + `host=${hostId} debug=${pending.debugName}`, + ); + if (shouldRunMounted && !host.hasMounted) { + host.hasMounted = true; + host.mounted?.(host.propsRef.current); + } return uikitHostHandles(host); } +function replayPendingNativeUIKitHostCreateRequest( + hostId: string, +): UIKitHostHandles | null { + "worklet"; + + const request = pendingNativeUIKitHostCreateRequestRegistry().get(hostId); + if (!request) { + return null; + } + + return createRegisteredUIKitHostFromNative( + hostId, + request.propsJson, + request.shouldRunMounted, + request.nativeMountInfoJson, + ); +} + function ensureRegisteredUIKitHost( hostId: string, ): RegisteredUIKitHost | null { @@ -1984,7 +4643,15 @@ function ensureRegisteredUIKitHost( return existingHost as RegisteredUIKitHost; } - if (createRegisteredUIKitHostFromNative(hostId) == null) { + const pending = pendingUIKitHostRegistry().get(hostId); + if ( + pending?.requiresNativeMountInfo === true && + pending.nativeMountInfoRef.current == null + ) { + return null; + } + + if (createRegisteredUIKitHostFromNative(hostId, undefined, false) == null) { return null; } @@ -2001,8 +4668,7 @@ function disposeRegisteredUIKitHost( pendingUIKitHostRegistry().delete(hostId); const registry = uikitHostRegistry(); const host = registry.get(hostId) as - | RegisteredUIKitHost - | undefined; + RegisteredUIKitHost | undefined; if (!host) { return; } @@ -2011,8 +4677,7 @@ function disposeRegisteredUIKitHost( const disposeResult = host.dispose?.(props); host.context.disposeResources(); const maybeView = host.hostInstance.hostView as - | Record - | undefined; + Record | undefined; if ( disposeResult?.removeHostView !== false && typeof maybeView?.removeFromSuperview === "function" @@ -2024,26 +4689,96 @@ function disposeRegisteredUIKitHost( function syncUIKitHostPropsFromReact( hostId: string, props: Readonly, -): void { + revision?: number, +): boolean { "worklet"; + let didApply = false; const pending = pendingUIKitHostRegistry().get(hostId); - if (pending) { + if ( + pending && + shouldApplyUIKitHostPropsRevision(pending.propsRevision, revision) + ) { pending.propsRef.current = props; + pending.propsRevision = revision ?? pending.propsRevision; + didApply = true; } const host = uikitHostRegistry().get(hostId); - if (host) { + if (host && shouldApplyUIKitHostPropsRevision(host.propsRevision, revision)) { host.propsRef.current = props; + host.propsRevision = revision ?? host.propsRevision; + didApply = true; + } + + return didApply; +} + +function commitUIKitHostFabricTransaction( + host: RegisteredUIKitHost, + props: Readonly, + previousProps: Readonly | undefined, + transaction: UIKitFabricTransaction, +): void { + "worklet"; + + if ( + host.mountingTransactionDidMount == null && + host.transactionCommitted == null + ) { + return; + } + + host.context.setFabricTransaction(transaction); + try { + host.mountingTransactionDidMount?.(props, previousProps); + host.transactionCommitted?.(props, previousProps); + } finally { + host.context.setFabricTransaction({ + children: [], + hasModifiedChildren: false, + hasModifiedProps: false, + mutations: [], + }); } } function runUIKitHostLifecycleFromNative( hostId: string, phase: string, + propsJson?: string, + transactionJson?: string, + nativeMountInfoJson?: string, ): UIKitHostHandles | null { "worklet"; + const profileHostCalls = + (globalThis as Record).__NS_NS_HOST_PROFILE === true; + const profileStart = profileHostCalls ? performance.now() : 0; + syncUIKitNativeMountInfo( + hostId, + parseUIKitNativeMountInfoJson(nativeMountInfoJson), + ); + const profileAfterMountInfo = profileHostCalls ? performance.now() : 0; + syncUIKitHostPropsFromNative(hostId, propsJson); + const profileAfterProps = profileHostCalls ? performance.now() : 0; + const profileSections = (label: string) => { + if (!profileHostCalls) { + return; + } + const total = performance.now() - profileStart; + if (total < 8) { + return; + } + console.warn( + `NS_NS_HOST_PROFILE_SECTIONS ${hostId} phase=${phase} ${label} mountInfo=${( + profileAfterMountInfo - profileStart + ).toFixed(1)} props=${(profileAfterProps - profileAfterMountInfo).toFixed( + 1, + )} rest=${(performance.now() - profileAfterProps).toFixed(1)}`, + ); + }; + if (phase === "dispose") { const host = uikitHostRegistry().get(hostId); const pending = pendingUIKitHostRegistry().get(hostId); @@ -2054,14 +4789,49 @@ function runUIKitHostLifecycleFromNative( return null; } - const handles = createRegisteredUIKitHostFromNative(hostId); + const profileBeforeCreate = profileHostCalls ? performance.now() : 0; + const handles = createRegisteredUIKitHostFromNative( + hostId, + undefined, + false, + nativeMountInfoJson, + ); + if (profileHostCalls) { + const createMs = performance.now() - profileBeforeCreate; + if (createMs >= 8) { + console.warn( + `NS_NS_HOST_PROFILE_SECTIONS ${hostId} phase=${phase} createMs=${createMs.toFixed(1)}`, + ); + } + } if (handles == null) { + profileSections("create-miss"); return null; } const host = getRegisteredUIKitHost(hostId); const nextProps = host.propsRef.current; - if (phase === "update") { + + if (phase === "refresh") { + const refreshingHosts = refreshingUIKitHostSet(); + if (!refreshingHosts.has(hostId)) { + refreshingHosts.add(hostId); + host.context.setFabricTransaction( + parseUIKitFabricTransactionJson(transactionJson), + ); + try { + host.refresh?.(nextProps, host.previousProps); + } finally { + host.context.setFabricTransaction({ + children: [], + hasModifiedChildren: false, + hasModifiedProps: false, + mutations: [], + }); + refreshingHosts.delete(hostId); + } + } + } else if (phase === "update") { if (host.previousProps !== nextProps) { host.update?.(nextProps, host.previousProps); host.previousProps = nextProps; @@ -2069,8 +4839,53 @@ function runUIKitHostLifecycleFromNative( } else if (phase === "mounted" && !host.hasMounted) { host.hasMounted = true; host.mounted?.(nextProps); + } else if (phase === "mountingTransactionWillMount") { + host.context.setFabricTransaction({ + children: [], + hasModifiedChildren: false, + hasModifiedProps: false, + mutations: [], + }); + host.mountingTransactionWillMount?.(nextProps, host.previousProps); + } else if (phase === "mountChild" || phase === "unmountChild") { + const child = parseUIKitFabricMountedChildJson(transactionJson); + if (child != null) { + host.context.setFabricTransaction({ + children: [], + hasModifiedChildren: true, + hasModifiedProps: false, + mutations: [], + }); + try { + if (phase === "mountChild") { + host.mountChild?.(child, nextProps, host.previousProps); + } else { + host.unmountChild?.(child, nextProps, host.previousProps); + } + } finally { + host.context.setFabricTransaction({ + children: [], + hasModifiedChildren: false, + hasModifiedProps: false, + mutations: [], + }); + } + } + } else if (phase === "transactionCommitted") { + commitUIKitHostFabricTransaction( + host, + nextProps, + host.previousProps, + parseUIKitFabricTransactionJson(transactionJson), + ); + } else if (phase === "hostReady") { + const hostReadyEvent = parseUIKitHostReadyEventJson(transactionJson); + if (hostReadyEvent != null) { + host.hostReady?.(nextProps, hostReadyEvent, host.previousProps); + } } + profileSections("exit"); return uikitHostHandles(host); } @@ -2103,50 +4918,23 @@ function installUIKitNativeMountBridge(): void { } } -function ignoreUIKitLayoutInvalidation(): void { - "worklet"; -} - -const targetActionClassGlobalName = "__nativeScriptUIKitTargetActionClass"; -const observerClassGlobalName = "__nativeScriptUIKitObserverClass"; -const targetActionCallbacksGlobalName = - "__nativeScriptUIKitTargetActionCallbacks"; -const observerCallbacksGlobalName = "__nativeScriptUIKitObserverCallbacks"; - -function objcInteropTypes(): any { - "worklet"; - - return (globalThis as Record).interop?.types; -} - -function runtimeGlobalMap(name: string): Map { +function applyUIKitHostPropsForFabricTagOnUI( + reactTag: number, + nextNativeProps: any, +): UIKitHostHandles | null { "worklet"; - const globalObject = globalThis as Record; - const existing = globalObject[name]; - if (existing instanceof Map) { - return existing as Map; + installUIKitNativeMountBridge(); + const applyHostProps = (globalThis as any) + .__nativeScriptApplyUIKitHostPropsForFabricTag; + if (typeof applyHostProps !== "function") { + return null; } + return applyHostProps(reactTag, nextNativeProps); +} - const map = new Map(); - Object.defineProperty(globalThis, name, { - configurable: true, - enumerable: false, - writable: false, - value: map, - }); - return map; -} - -function targetActionCallbacksForRuntime(): Map< - string, - (sender: unknown) => void -> { - "worklet"; - - return runtimeGlobalMap<(sender: unknown) => void>( - targetActionCallbacksGlobalName, - ); +function ignoreUIKitLayoutInvalidation(): void { + "worklet"; } function observerCallbacksForRuntime(): Map< @@ -2160,61 +4948,55 @@ function observerCallbacksForRuntime(): Map< >(observerCallbacksGlobalName); } -function nativeCallbackKey(value: unknown): string { +function invokeNativeActionTargetFromRuntime( + actionTarget: + | Pick + | null + | undefined, + sender?: unknown, +): boolean { "worklet"; - const handleof = (globalThis as Record).interop?.handleof; - if (value != null && typeof handleof === "function") { - const handle = handleof(value); - if (handle != null) { - if (typeof handle.toHexString === "function") { - return handle.toHexString(); - } - return String(handle); - } + if (typeof actionTarget?.invoke === "function") { + return actionTarget.invoke(sender) === true; } - return String(value); + + const target = actionTarget?.target; + if (target == null) { + return false; + } + + const targetKey = + typeof actionTarget.callbackKey === "string" + ? actionTarget.callbackKey + : nativeCallbackKey(target); + const callback = targetActionCallbacksForRuntime().get(targetKey); + if (typeof callback !== "function") { + return false; + } + + callback(sender); + return true; } -function getTargetActionClass(): any { +function installNativeActionTargetInvoker(): void { "worklet"; - const globalObject = globalThis as Record; - const cached = globalObject[targetActionClassGlobalName]; - if (cached) { - return cached; + const globalObject = globalThis as Record; + if (typeof globalObject[invokeNativeActionTargetGlobalName] === "function") { + return; } - const types = objcInteropTypes(); - const NSObject = requireNSObject(); - const targetActionClass = NSObject.extend( - { - nativeScriptHandleAction(sender: unknown) { - const callback = targetActionCallbacksForRuntime().get( - nativeCallbackKey(this), - ); - if (typeof callback === "function") { - callback(sender); - } - }, - }, - { - exposedMethods: { - "nativeScriptHandleAction:": { - returns: types?.void, - params: [NSObject], - }, - }, - }, - ); - Object.defineProperty(globalThis, targetActionClassGlobalName, { + + Object.defineProperty(globalThis, invokeNativeActionTargetGlobalName, { configurable: true, enumerable: false, writable: false, - value: targetActionClass, + value: invokeNativeActionTargetFromRuntime, }); - return targetActionClass; } +installNativeActionTargetInvoker(); + function getObserverClass(): any { "worklet"; @@ -2225,17 +5007,18 @@ function getObserverClass(): any { } const types = objcInteropTypes(); const NSObject = requireNSObject(); - const NSString = (globalThis as Record).NSString; - const NSDictionary = (globalThis as Record).NSDictionary; + const NSString = nativeApiClass("NSString"); + const NSDictionary = nativeApiClass("NSDictionary"); const Pointer = (globalThis as Record).interop?.Pointer ?? types?.id; const observerClass = NSObject.extend( { - "observeValueForKeyPath:ofObject:change:context:"( + observeValueForKeyPathOfObjectChangeContext( keyPath: string, object: unknown, change: unknown, + _context: unknown, ) { const callback = observerCallbacksForRuntime().get( nativeCallbackKey(this), @@ -2272,12 +5055,21 @@ function createUIKitContext( name: string, propsRef: { current: Props }, invalidateLayout: () => void, + nativeMountInfoRef: { current: UIKitNativeMountInfo | null } = { + current: null, + }, ): UIKitRuntimeContext { "worklet"; const retained: unknown[] = []; const cleanupCallbacks: Array<() => void> = []; let disposed = false; + let fabricTransaction: UIKitFabricTransaction = { + children: [], + hasModifiedChildren: false, + hasModifiedProps: false, + mutations: [], + }; const context: UIKitRuntimeContext = { get name() { @@ -2289,6 +5081,27 @@ function createUIKitContext( get props() { return propsRef.current; }, + get fabricComponentView() { + return nativeMountInfoRef.current?.fabricComponentView ?? null; + }, + get fabricComponentViewHandle() { + return nativeMountInfoRef.current?.fabricComponentViewHandle ?? ""; + }, + get fabricContainerView() { + return nativeMountInfoRef.current?.fabricContainerView ?? null; + }, + get fabricContainerViewHandle() { + return nativeMountInfoRef.current?.fabricContainerViewHandle ?? ""; + }, + get fabricTransaction() { + return fabricTransaction; + }, + setNativeMountInfo(info) { + nativeMountInfoRef.current = info; + }, + setFabricTransaction(transaction) { + fabricTransaction = transaction; + }, emit(eventName, payload) { if (disposed) { return; @@ -2320,7 +5133,7 @@ function createUIKitContext( if (control == null || typeof callback !== "function") { return; } - const target = getTargetActionClass().alloc().init(); + const target = createNativeClassInstance(getTargetActionClass()); const targetKey = nativeCallbackKey(target); targetActionCallbacksForRuntime().set(targetKey, () => { if (!disposed) { @@ -2351,7 +5164,7 @@ function createUIKitContext( if (gesture == null || typeof callback !== "function") { return; } - const target = getTargetActionClass().alloc().init(); + const target = createNativeClassInstance(getTargetActionClass()); const targetKey = nativeCallbackKey(target); targetActionCallbacksForRuntime().set(targetKey, (sender) => { if (!disposed) { @@ -2379,12 +5192,18 @@ function createUIKitContext( throw new Error("actionTarget expects a callback"); } - const target = getTargetActionClass().alloc().init(); + const target = createNativeClassInstance(getTargetActionClass()); const targetKey = nativeCallbackKey(target); - targetActionCallbacksForRuntime().set(targetKey, (sender) => { + const invoke = (sender?: unknown) => { if (!disposed) { invokeNativeScriptCallback(callback, [sender], () => disposed); + return true; } + + return false; + }; + targetActionCallbacksForRuntime().set(targetKey, (sender) => { + invoke(sender); }); context.retain(target); context.dispose(() => { @@ -2392,11 +5211,13 @@ function createUIKitContext( }); return { - target, action: "nativeScriptHandleAction:", + callbackKey: targetKey, + invoke, + target, }; }, - delegate(object, protocolRef, implementation) { + delegate(object, protocolRef, implementation, options = {}) { const protocolList = [protocolRef as NativeProtocolReference] .map(resolveProtocolReference) .filter(Boolean); @@ -2405,30 +5226,42 @@ function createUIKitContext( } const nativeObject = object as Record; - const assignedObject = + const fallbackAssignedObject = nativeObject && "delegate" in nativeObject ? nativeObject : undefined; + const assignedObject = (options.assignTo?.object ?? + fallbackAssignedObject) as Record | undefined; + const assignedProperty = options.assignTo?.property ?? "delegate"; + const delegateClassOptions: Record = { + protocols: protocolList, + }; + if (options.name) { + delegateClassOptions.name = options.name; + } const DelegateClass = requireNSObject().extend( - wrapDelegateMethods(implementation, "caller"), - { - protocols: protocolList, - }, + wrapDelegateMethods(implementation, options.thread ?? "caller"), + delegateClassOptions, ); - const delegate = DelegateClass.alloc().init() as T; - context.retain(delegate); + const delegate = createNativeClassInstance(DelegateClass); + const owner = options.owner ?? context; + if (options.retainer) { + options.retainer.retain(delegate); + } else { + owner.retain(delegate); + } if (assignedObject) { - assignedObject.delegate = delegate; + assignedObject[assignedProperty] = delegate; } - context.dispose(() => { - if (assignedObject && assignedObject.delegate === delegate) { - assignedObject.delegate = null; + owner.dispose?.(() => { + if (assignedObject && assignedObject[assignedProperty] === delegate) { + assignedObject[assignedProperty] = null; } - context.release(delegate); + owner.release?.(delegate); + options.retainer?.release(delegate); }); return delegate; }, notification(name, object, callback) { - const center = (globalThis as Record).NSNotificationCenter - ?.defaultCenter; + const center = nativeApiClass("NSNotificationCenter")?.defaultCenter; if (!center) { throw new Error("NSNotificationCenter.defaultCenter is not available"); } @@ -2455,7 +5288,7 @@ function createUIKitContext( ) { throw new Error("observe expects a KVO-compatible NSObject"); } - const observer = getObserverClass().alloc().init(); + const observer = createNativeClassInstance(getObserverClass()); const observerKey = nativeCallbackKey(observer); observerCallbacksForRuntime().set( observerKey, @@ -2467,8 +5300,7 @@ function createUIKitContext( if (disposed || String(observedKeyPath) !== keyPath) { return; } - const newKey = (globalThis as Record) - .NSKeyValueChangeNewKey; + const newKey = nativeApiValue("NSKeyValueChangeNewKey"); const value = change && typeof (change as Record).objectForKey === @@ -2478,13 +5310,13 @@ function createUIKitContext( callback(value, change); }, ); - const options = (globalThis as Record) - .NSKeyValueObservingOptions; + const options = nativeApiEnum("NSKeyValueObservingOptions") as + Record | undefined; const optionNew = typeof options?.New === "number" ? options.New - : ((globalThis as Record).NSKeyValueObservingOptionNew ?? - 1); + : ((nativeApiValue("NSKeyValueObservingOptionNew") as + number | undefined) ?? 1); nativeObject.addObserverForKeyPathOptionsContext( observer, keyPath, @@ -2506,26 +5338,26 @@ function createUIKitContext( retained.push(value); return value; }, - release(value?: unknown) { - if (arguments.length === 0) { - retained.length = 0; - return; - } + release(value?: unknown) { + if (arguments.length === 0) { + retained.length = 0; + return; + } for (let i = retained.length - 1; i >= 0; i--) { if (retained[i] === value) { retained.splice(i, 1); } } }, - dispose(callback) { - cleanupCallbacks.push(callback); - }, - invalidateLayout, - loadImage: (source, options, callback) => - loadImage(source, options, callback), - createArgument() { - return Object.assign(Object.create(context), propsRef.current); - }, + dispose(callback) { + cleanupCallbacks.push(callback); + }, + invalidateLayout, + loadImage: (source, options, callback) => + loadImage(source, options, callback), + createArgument() { + return Object.assign(Object.create(context), propsRef.current); + }, disposeResources() { if (disposed) { return; @@ -2553,11 +5385,11 @@ function constrainedSize( const defaultSize = layout?.defaultSize ?? {}; let width = - Number.isFinite(size.width) && size.width >= 0 + isFiniteNumber(size.width) && size.width >= 0 ? size.width : (defaultSize.width ?? 0); let height = - Number.isFinite(size.height) && size.height >= 0 + isFiniteNumber(size.height) && size.height >= 0 ? size.height : (defaultSize.height ?? 0); @@ -2608,7 +5440,7 @@ function flattenedStyleSize(style: ViewProps["style"]) { function makeCGSize(width: number, height: number) { "worklet"; - const CGSizeMake = (globalThis as Record).CGSizeMake; + const CGSizeMake = nativeApiValue("CGSizeMake"); if (typeof CGSizeMake === "function") { return CGSizeMake(width, height); } @@ -2663,7 +5495,7 @@ function measureUIKitView( typeof nativeView.systemLayoutSizeFittingSize === "function" ) { const fittingSize = - (globalThis as Record).UIView?.layoutFittingCompressedSize ?? + nativeApiClass("UIView")?.layoutFittingCompressedSize ?? makeCGSize(styleSize.width ?? 0, styleSize.height ?? 0); measured = readNativeSize( nativeView.systemLayoutSizeFittingSize(fittingSize), @@ -2687,34 +5519,130 @@ function defineUIKitHost( definition.name || definition.displayName || "NativeScriptUIKitView"; + const createHost = definition.create; + const updateHost = definition.update; + const mountedHost = definition.mounted; + const disposeHost = definition.dispose; + const refreshHost = definition.refresh; + const transactionCommittedHost = definition.transactionCommitted; + const mountingTransactionWillMountHost = + definition.mountingTransactionWillMount; + const mountingTransactionDidMountHost = + definition.mountingTransactionDidMount; + const mountChildHost = definition.mountChild; + const unmountChildHost = definition.unmountChild; + const hostReadyHost = definition.hostReady; + const resolveHostInstance = definition.resolveHostInstance; + const layout = definition.layout; + const requiresNativeMountInfo = definition.requiresNativeMountInfo === true; + const hasFabricLifecycleCallbacks = + mountingTransactionWillMountHost != null || + mountingTransactionDidMountHost != null || + transactionCommittedHost != null || + mountChildHost != null || + unmountChildHost != null || + hostReadyHost != null; const Component = forwardRef< UIKitViewRef, Props & UIKitHostViewProps - >(function NativeScriptUIKitView(props, ref) { - const { nativeProps, pluginProps } = splitUIKitViewProps(props, definition); - const createHost = definition.create; - const updateHost = definition.update; - const mountedHost = definition.mounted; - const disposeHost = definition.dispose; - const resolveHostInstance = definition.resolveHostInstance; - const layout = definition.layout; + >(function NativeScriptUIKitView(rawProps, ref) { + const props = (rawProps ?? {}) as Props & UIKitHostViewProps; + if (typeof splitUIKitViewProps !== "function") { + throw jsError( + `${debugName} expected splitUIKitViewProps to be a function, got ${typeof splitUIKitViewProps}`, + ); + } + let splitProps: { + nativeProps: ViewProps; + pluginProps: Props & UIKitHostViewProps; + }; + try { + splitProps = splitUIKitViewProps(props, definition); + } catch (reason) { + throw jsError( + `${debugName} splitUIKitViewProps failed: ${jsString(reason)}`, + ); + } + const nativeProps = splitProps.nativeProps; + const pluginProps = splitProps.pluginProps; + if (typeof useRef !== "function") { + throw jsError( + `${debugName} expected React.useRef to be a function, got ${typeof useRef}`, + ); + } + if (typeof useState !== "function") { + throw jsError( + `${debugName} expected React.useState to be a function, got ${typeof useState}`, + ); + } + if (typeof createUIKitHostId !== "function") { + throw jsError( + `${debugName} expected createUIKitHostId to be a function, got ${typeof createUIKitHostId}`, + ); + } const layoutSizing = layout?.sizing ?? "fill"; const hostIdRef = useRef(null); if (hostIdRef.current == null) { hostIdRef.current = createUIKitHostId(debugName); } const hostId = hostIdRef.current; + const nativeComponentRef = useRef(null); const propsRef = useRef(pluginProps); + const reactHostPropsRevisionRef = useRef(0); + const reactHostPropsJsonRef = useRef(); + const reactHostRevisionPropsRef = useRef< + Readonly | undefined + >(); const previousPropsRef = useRef | undefined>(); const mountedRef = useRef(false); const disposedRef = useRef(false); + const asyncPreparedHostRef = useRef<{ + propsRevision: number | undefined; + } | null>(null); const updateMeasuredSizeRef = useRef<() => void>(() => {}); const [nativeHostRevision, setNativeHostRevision] = useState(0); const attachController = props.attachController !== false; + const adoptHostViewAsControllerView = + props.adoptHostViewAsControllerView === true; + const attachControllerToParent = props.attachControllerToParent !== false; const attachControllerView = props.attachControllerView !== false; const attachNativeView = props.attachNativeView !== false; - const mountThroughNativeHost = attachController; + const detachControllerFromParent = + props.detachControllerFromParent === true; + const collectChildren = props.collectChildren === true; + const pinNativeViewToHost = props.pinNativeViewToHost === true; + const disableDetachedChildrenTouchHandler = + props.disableDetachedChildrenTouchHandler === true; + const disableUIKitHostWindowAttachRefresh = + props.disableUIKitHostWindowAttachRefresh === true; + const emitOffWindowHostReady = props.emitOffWindowHostReady === true; + const ignoreHostReadyWindowAttachment = + props.ignoreHostReadyWindowAttachment === true; + const externalDetachedChildrenOwner = + props.externalDetachedChildrenOwner === true; + const preserveDetachedChildrenLayout = + props.preserveDetachedChildrenLayout === true; + const mountChildrenDirectlyToChildrenView = + props.mountChildrenDirectlyToChildrenView === true; + const layoutDirectChildrenToChildrenViewBounds = + props.layoutDirectChildrenToChildrenViewBounds === true; + const detachedChildrenContentOffsetX = isFiniteNumber( + props.detachedChildrenContentOffsetX, + ) + ? props.detachedChildrenContentOffsetX + : undefined; + const detachedChildrenContentOffsetY = isFiniteNumber( + props.detachedChildrenContentOffsetY, + ) + ? props.detachedChildrenContentOffsetY + : undefined; + const mountThroughNativeHost = true; + const nativeHostPropsJsonRef = useRef<{ + json: string | undefined; + payloadJson?: string; + revision: number; + }>({ json: undefined, revision: 0 }); const invalidateLayout = () => { updateMeasuredSizeRef.current(); @@ -2743,7 +5671,91 @@ function defineUIKitHost( ); const [error, setError] = useState(null); + const nextSerializableReactHostPropsJson = + stringifySerializableUIKitHostProps(pluginProps); + const nextSerializableNativeHostPropsJson = mountThroughNativeHost + ? nextSerializableReactHostPropsJson + : undefined; + const didSerializableHostPropsChange = + reactHostPropsJsonRef.current !== nextSerializableReactHostPropsJson; + const didLiveHostPropsChange = + didSerializableHostPropsChange || + nonSerializableUIKitHostPropsChanged( + reactHostRevisionPropsRef.current, + pluginProps, + ); + propsRef.current = pluginProps; + const uiRuntimeProps = copyUIKitHostPropsForUI(pluginProps) as Readonly< + Props & UIKitHostViewProps + >; + + if (didLiveHostPropsChange) { + reactHostPropsRevisionRef.current += 1; + reactHostPropsJsonRef.current = nextSerializableReactHostPropsJson; + reactHostRevisionPropsRef.current = pluginProps; + } + + const reactHostPropsRevision = reactHostPropsRevisionRef.current; + + if (didSerializableHostPropsChange) { + nativeHostPropsJsonRef.current = { + json: nextSerializableNativeHostPropsJson, + payloadJson: + mountThroughNativeHost && nextSerializableNativeHostPropsJson != null + ? stringifyUIKitHostPropsPayload( + nextSerializableNativeHostPropsJson, + reactHostPropsRevision, + ) + : undefined, + revision: nativeHostPropsJsonRef.current.revision + 1, + }; + } + const nativeHostPropsJson = nativeHostPropsJsonRef.current.payloadJson; + const nativeHostPropsRevision = nativeHostPropsJsonRef.current.revision; + const nativeFabricHostProps = mountThroughNativeHost + ? { + adoptHostViewAsControllerView, + attachNativeView, + attachControllerToParent: attachController + ? attachControllerToParent + : false, + collectChildren, + detachControllerFromParent: + attachController && detachControllerFromParent, + detachControllerView: attachController && !attachControllerView, + disableDetachedChildrenTouchHandler, + disableUIKitHostWindowAttachRefresh, + emitOffWindowHostReady, + ignoreHostReadyWindowAttachment, + externalDetachedChildrenOwner, + fabricLifecycleCallbacks: + hasFabricLifecycleCallbacks || + props.fabricLifecycleCallbacks === true, + immediateTransactionCommit: props.immediateTransactionCommit === true, + deferTransactionCommitOnRemovals: + props.deferTransactionCommitOnRemovals === true, + mountChildrenDirectlyToChildrenView, + layoutDirectChildrenToChildrenViewBounds, + pinNativeViewToHost, + preserveDetachedChildrenLayout, + detachedChildrenContentOffsetX: detachedChildrenContentOffsetX ?? 0, + detachedChildrenContentOffsetY: detachedChildrenContentOffsetY ?? 0, + debugName, + hostReadyId: hostId, + hostId, + mountedRevision: + mountedHost != null && nativeHostRevision > 0 + ? nativeHostRevision + : 0, + nativeViewHandle: nativeViewHandle ?? "", + childrenViewHandle: childrenViewHandle ?? "", + controllerHandle: attachController ? (controllerHandle ?? "") : "", + uikitHostPropsJson: nativeHostPropsJson ?? "", + uikitHostPropsRevision: nativeHostPropsRevision, + updateRevision: nativeHostPropsRevision, + } + : null; const applyHostHandles = (handles: UIKitHostHandles | null | undefined) => { if (handles == null) { @@ -2767,8 +5779,237 @@ function defineUIKitHost( ); }; + const prepareUIKitHostOnUI = ( + currentProps: Readonly, + currentPropsRevision: number | undefined, + createImmediately: boolean, + ): UIKitHostHandles | null => { + "worklet"; + + installUIKitNativeMountBridge(); + + const existingHost = uikitHostRegistry().get(hostId); + if (existingHost) { + if ( + shouldApplyUIKitHostPropsRevision( + existingHost.propsRevision, + currentPropsRevision, + ) + ) { + existingHost.propsRef.current = currentProps; + existingHost.propsRevision = + currentPropsRevision ?? existingHost.propsRevision; + } + return uikitHostHandles(existingHost); + } + + const registry = pendingUIKitHostRegistry(); + const pending = registry.get(hostId) as + PendingUIKitHost | undefined; + const pendingPropsRef = pending?.propsRef ?? { current: currentProps }; + const pendingNativeMountInfoRef = pending?.nativeMountInfoRef ?? { + current: null, + }; + const shouldApplyPendingProps = + !pending || + shouldApplyUIKitHostPropsRevision( + pending.propsRevision, + currentPropsRevision, + ); + if (shouldApplyPendingProps) { + pendingPropsRef.current = currentProps; + } + const pendingPropsRevision = shouldApplyPendingProps + ? (currentPropsRevision ?? pending?.propsRevision) + : pending?.propsRevision; + + const mountHost = () => { + "worklet"; + + const latest = pendingUIKitHostRegistry().get(hostId) as + PendingUIKitHost | undefined; + const latestPropsRef = latest?.propsRef ?? pendingPropsRef; + const nextProps = latestPropsRef.current; + const nextPropsRevision = latest?.propsRevision ?? pendingPropsRevision; + const context = createUIKitContext( + debugName, + latestPropsRef, + ignoreUIKitLayoutInvalidation, + latest?.nativeMountInfoRef ?? pendingNativeMountInfoRef, + ); + const created = createHost(context.createArgument()); + const hostInstance = resolveHostInstance + ? resolveHostInstance(created) + : { hostView: created, lifecycleValue: created }; + const nativeView = hostInstance.lifecycleValue; + updateHost?.(nativeView, nextProps, undefined, context); + return { + context, + dispose(disposeProps: Readonly) { + return disposeHost?.(nativeView, disposeProps, context); + }, + mounted(mountedProps: Readonly) { + mountedHost?.(nativeView, mountedProps, context); + }, + hostInstance, + nativeView, + previousProps: nextProps, + propsRevision: nextPropsRevision, + propsRef: latestPropsRef, + refresh( + refreshProps: Readonly, + previousProps: Readonly | undefined, + ) { + refreshHost?.(nativeView, refreshProps, previousProps, context); + }, + hostReady( + readyProps: Readonly, + event: UIKitHostReadyEvent, + previousProps: Readonly | undefined, + ) { + hostReadyHost?.( + nativeView, + readyProps, + event, + previousProps, + context, + ); + }, + transactionCommitted( + transactionProps: Readonly, + previousProps: Readonly | undefined, + ) { + transactionCommittedHost?.( + nativeView, + transactionProps, + previousProps, + context, + ); + }, + mountingTransactionWillMount( + transactionProps: Readonly, + previousProps: Readonly | undefined, + ) { + mountingTransactionWillMountHost?.( + nativeView, + transactionProps, + previousProps, + context, + ); + }, + mountingTransactionDidMount( + transactionProps: Readonly, + previousProps: Readonly | undefined, + ) { + mountingTransactionDidMountHost?.( + nativeView, + transactionProps, + previousProps, + context, + ); + }, + mountChild( + child: UIKitFabricMountedChild, + childProps: Readonly, + previousProps: Readonly | undefined, + ) { + mountChildHost?.( + nativeView, + child, + childProps, + previousProps, + context, + ); + }, + unmountChild( + child: UIKitFabricMountedChild, + childProps: Readonly, + previousProps: Readonly | undefined, + ) { + unmountChildHost?.( + nativeView, + child, + childProps, + previousProps, + context, + ); + }, + update( + updateProps: Readonly, + previousProps: Readonly | undefined, + ) { + updateHost?.(nativeView, updateProps, previousProps, context); + }, + }; + }; + + registry.set(hostId, { + debugName, + mountHost, + nativeMountInfoRef: pendingNativeMountInfoRef, + propsRevision: pendingPropsRevision, + propsRef: pendingPropsRef, + requiresNativeMountInfo, + }); + + const replayedHandles = replayPendingNativeUIKitHostCreateRequest(hostId); + if (replayedHandles != null) { + return replayedHandles; + } + + return createImmediately + ? createRegisteredUIKitHostFromNative(hostId, undefined, false) + : null; + }; + + const prepareAsyncKey = reactHostPropsRevision; + // Native-mount-info hosts need their UI-runtime factory registered before + // Fabric commits the component view. Native still performs create() with + // the real Fabric handles; this only closes the pending-missing race. + if ( + asyncPreparedHostRef.current == null || + asyncPreparedHostRef.current.propsRevision !== prepareAsyncKey + ) { + asyncPreparedHostRef.current = { + propsRevision: prepareAsyncKey, + }; + if (mountThroughNativeHost && requiresNativeMountInfo) { + runOnUISync( + prepareUIKitHostOnUI, + uiRuntimeProps, + reactHostPropsRevision, + false, + ); + } else { + runOnUI( + prepareUIKitHostOnUI, + uiRuntimeProps, + reactHostPropsRevision, + false, + ) + .then((handles) => { + if (disposedRef.current) { + return; + } + previousPropsRef.current = propsRef.current; + applyHostHandles(handles); + if (handles != null) { + setNativeHostRevision((revision) => revision + 1); + } + }) + .catch((reason) => { + setError( + reason instanceof Error ? reason : new Error(String(reason)), + ); + }); + } + } + const updateMeasuredSize = () => { - if (nativeViewHandle == null || layoutSizing === "fill") { + if ( + (!mountThroughNativeHost && nativeViewHandle == null) || + layoutSizing === "fill" + ) { return; } runOnUI(() => { @@ -2832,74 +6073,50 @@ function defineUIKitHost( ensureNativeScriptInstalled(); if (mountThroughNativeHost) { - const effectProps = propsRef.current; - runOnUI((currentProps) => { - installUIKitNativeMountBridge(); - - const existingHost = uikitHostRegistry().get(hostId); - if (existingHost) { - existingHost.propsRef.current = currentProps; - return uikitHostHandles(existingHost); - } - - const registry = pendingUIKitHostRegistry(); - const pending = registry.get(hostId) as - | PendingUIKitHost - | undefined; - const pendingPropsRef = pending?.propsRef ?? { - current: currentProps, - }; - pendingPropsRef.current = currentProps; - - const mountHost = () => { - const nextProps = pendingPropsRef.current; - const context = createUIKitContext( - debugName, - pendingPropsRef, - ignoreUIKitLayoutInvalidation, - ); - const created = createHost(context.createArgument()); - const hostInstance = resolveHostInstance - ? resolveHostInstance(created) - : { hostView: created, lifecycleValue: created }; - const nativeView = hostInstance.lifecycleValue; - updateHost?.(nativeView, nextProps, undefined, context); - return { - context, - dispose(disposeProps: Readonly) { - return disposeHost?.(nativeView, disposeProps, context); - }, - mounted(mountedProps: Readonly) { - mountedHost?.(nativeView, mountedProps, context); - }, - hostInstance, - nativeView, - previousProps: nextProps, - propsRef: pendingPropsRef, - update( - updateProps: Readonly, - previousProps: Readonly | undefined, - ) { - updateHost?.(nativeView, updateProps, previousProps, context); - }, - }; - }; - - registry.set(hostId, { - debugName, - mountHost, - propsRef: pendingPropsRef, - }); - - return null; - }, effectProps) + const effectProps = uiRuntimeProps; + const effectPropsRevision = reactHostPropsRevision; + const bootstrapTag = findNodeHandle(nativeComponentRef.current); + const bootstrapProps = nativeFabricHostProps; + // NOTE: this used to also fire a synchronous runOnUISync( + // applyUIKitHostPropsForFabricTagOnUI) bootstrap here. useLayoutEffect + // always runs on the RN JS thread, and since the runtimeMutex_ + // lock-hierarchy fix (nativeScriptApplyUIKitHostPropsForFabricTag + // dispatch_async-ing to main instead of blocking on it off-main), + // that call could never receive real handles off-main -- it just + // parked the JS thread on the worklet runtime mutex_ for as long as + // main held it in the nav reconcile, then resolved null (a no-op). + // The handles + the missing-host race-close are already owned by + // the async prepare -> apply chain below plus the pending-create + // replay (see createRegisteredUIKitHostFromNative / + // replayPendingNativeUIKitHostCreateRequest), so the sync bootstrap + // was dead weight and has been removed. + runOnUI(prepareUIKitHostOnUI, effectProps, effectPropsRevision, false) + .then((preparedHandles) => { + if (typeof bootstrapTag !== "number" || bootstrapProps == null) { + return preparedHandles; + } + // applyUIKitHostPropsForFabricTagOnUI now resolves null when it + // runs off-main (it dispatches async-to-main instead of + // blocking on it -- see the lock-hierarchy comment above and in + // NativeScriptNativeApiModule.mm). Fall back to the handles we + // already prepared so this bootstrap doesn't regress to no + // handles at all; the real ones land via the update-effect + // fallback below once the async main-thread apply completes. + return runOnUI( + applyUIKitHostPropsForFabricTagOnUI, + bootstrapTag, + bootstrapProps, + ).then((h) => h ?? preparedHandles); + }) .then((handles) => { if (cancelled || disposedRef.current) { return; } previousPropsRef.current = propsRef.current; applyHostHandles(handles); - setNativeHostRevision((revision) => revision + 1); + if (handles != null) { + setNativeHostRevision((revision) => revision + 1); + } updateMeasuredSize(); }) .catch((reason) => { @@ -2912,11 +6129,12 @@ function defineUIKitHost( cancelled = true; disposedRef.current = true; mountedRef.current = false; - runOnUI(() => { - if (!uikitHostRegistry().has(hostId)) { - pendingUIKitHostRegistry().delete(hostId); - } - }).catch((reason) => { + const disposeProps = copyUIKitHostPropsForUI( + propsRef.current, + ) as Readonly; + runOnUI((currentProps) => { + disposeRegisteredUIKitHost(hostId, currentProps); + }, disposeProps).catch((reason) => { setError( reason instanceof Error ? reason : new Error(String(reason)), ); @@ -2924,70 +6142,17 @@ function defineUIKitHost( }; } - const effectProps = propsRef.current; - runOnUI((currentProps) => { - installUIKitNativeMountBridge(); - - const existingHost = uikitHostRegistry().get(hostId); - if (existingHost) { - existingHost.propsRef.current = currentProps; - return uikitHostHandles(existingHost); - } - - const registry = pendingUIKitHostRegistry(); - const pending = registry.get(hostId) as - | PendingUIKitHost - | undefined; - const pendingPropsRef = pending?.propsRef ?? { current: currentProps }; - pendingPropsRef.current = currentProps; - - const mountHost = () => { - const nextProps = pendingPropsRef.current; - const context = createUIKitContext( - debugName, - pendingPropsRef, - ignoreUIKitLayoutInvalidation, - ); - const created = createHost(context.createArgument()); - const hostInstance = resolveHostInstance - ? resolveHostInstance(created) - : { hostView: created, lifecycleValue: created }; - const nativeView = hostInstance.lifecycleValue; - updateHost?.(nativeView, nextProps, undefined, context); - return { - context, - dispose(disposeProps: Readonly) { - return disposeHost?.(nativeView, disposeProps, context); - }, - mounted(mountedProps: Readonly) { - mountedHost?.(nativeView, mountedProps, context); - }, - hostInstance, - nativeView, - previousProps: nextProps, - propsRef: pendingPropsRef, - update( - updateProps: Readonly, - previousProps: Readonly | undefined, - ) { - updateHost?.(nativeView, updateProps, previousProps, context); - }, - }; - }; - - registry.set(hostId, { - debugName, - mountHost, - propsRef: pendingPropsRef, - }); - return createRegisteredUIKitHostFromNative(hostId); - }, effectProps) + const effectProps = uiRuntimeProps; + const effectPropsRevision = reactHostPropsRevision; + runOnUI(prepareUIKitHostOnUI, effectProps, effectPropsRevision, true) .then((handles) => { if (handles == null) { throw new Error(`UIKit host ${hostId} was not created`); } if (cancelled || disposedRef.current) { - const disposeProps = propsRef.current; + const disposeProps = copyUIKitHostPropsForUI( + propsRef.current, + ) as Readonly; runOnUI((currentProps) => { disposeRegisteredUIKitHost(hostId, currentProps); }, disposeProps).catch((reason) => { @@ -3011,7 +6176,9 @@ function defineUIKitHost( cancelled = true; disposedRef.current = true; mountedRef.current = false; - const disposeProps = propsRef.current; + const disposeProps = copyUIKitHostPropsForUI( + propsRef.current, + ) as Readonly; runOnUI((currentProps) => { disposeRegisteredUIKitHost(hostId, currentProps); }, disposeProps).catch((reason) => { @@ -3027,41 +6194,123 @@ function defineUIKitHost( hostId, mountedHost, mountThroughNativeHost, + refreshHost, + hostReadyHost, + mountingTransactionDidMountHost, + mountingTransactionWillMountHost, + mountChildHost, + requiresNativeMountInfo, resolveHostInstance, + transactionCommittedHost, + unmountChildHost, updateHost, ]); - useEffect(() => { + useLayoutEffect(() => { if (nativeViewHandle == null && !mountThroughNativeHost) { return; } - const currentProps = propsRef.current; + const currentProps = uiRuntimeProps; const previousProps = previousPropsRef.current; previousPropsRef.current = currentProps; if (mountThroughNativeHost) { + const currentPropsRevision = reactHostPropsRevision; + const currentNativePropsRevision = nativeHostPropsRevision; + const bootstrapTag = findNodeHandle(nativeComponentRef.current); + const bootstrapProps = nativeFabricHostProps; runOnUI( - (nextProps, fallbackPreviousProps) => { - syncUIKitHostPropsFromReact(hostId, nextProps); + ( + nextProps, + fallbackPreviousProps, + nextPropsRevision, + nextNativeRevision, + reactTag, + nextNativeProps, + ) => { + let nativeHandles: UIKitHostHandles | null = null; + if (typeof reactTag === "number" && nextNativeProps != null) { + nativeHandles = applyUIKitHostPropsForFabricTagOnUI( + reactTag, + nextNativeProps, + ); + } + const didApplyProps = syncUIKitHostPropsFromReact( + hostId, + nextProps, + nextPropsRevision, + ); const host = ensureRegisteredUIKitHost(hostId); if (!host) { return null; } - host.propsRef.current = nextProps; - updateHost?.( - host.nativeView, - nextProps, - host.previousProps ?? fallbackPreviousProps, - host.context, + + // Lever 2: only invoke host.update()/commitUIKitHostFabricTransaction + // (and therefore the fork's native update/reconcile handlers) when + // the SERIALIZABLE native payload actually advanced. Gating on + // "this component merely HAS function props" (the old + // shouldUpdateNativeHostFromReactProps check) fired this branch on + // every function-identity-only re-render -- Animated.event/inline + // react-navigation handlers are recreated every render -- even + // when nothing serializable changed. Confirmed via runtime probe: + // this fired + reached this branch on every observed pop cycle + // with the native (serializable) revision static, driving a + // same-commit spurious reconcile in the fork's screen update() + // handler during the pop commit window. Real prop changes still + // update exactly once, since nativeHostPropsRevision only bumps + // on a genuine serializable change (index.ts ~5880-5892). + // Identity-only churn still runs syncUIKitHostPropsFromReact + // above (propsRef refresh) so live callbacks stay fresh -- it + // just skips this branch. + const nativeRevisionAdvanced = shouldApplyUIKitHostPropsRevision( + host.updateAppliedNativeRevision, + nextNativeRevision, ); - host.previousProps = nextProps; - return uikitHostHandles(host); + if (didApplyProps && nativeRevisionAdvanced) { + const updatePreviousProps = + host.previousProps ?? fallbackPreviousProps; + host.update?.(nextProps, updatePreviousProps); + commitUIKitHostFabricTransaction( + host, + nextProps, + updatePreviousProps, + { + children: [], + hasModifiedChildren: false, + hasModifiedProps: true, + mutations: [], + }, + ); + host.previousProps = nextProps; + host.propsRevision = nextPropsRevision ?? host.propsRevision; + host.updateAppliedNativeRevision = + nextNativeRevision ?? host.updateAppliedNativeRevision; + } + + return nativeHandles ?? uikitHostHandles(host); }, currentProps, previousProps, + currentPropsRevision, + currentNativePropsRevision, + bootstrapTag, + bootstrapProps, ) - .then(applyHostHandles) + .then((handles) => { + // Bug B fix: apply host handles UNCONDITIONALLY (restore + // pre-3fd29322 behavior). 3fd29322 had gated this on + // `layoutSizing !== "fill"`, which starved fill-sizing hosts + // (the thin adapter's screen/modal hosts) of the per-commit + // handle feedback loop -- their childrenViewHandle never + // converged in JS state, so the React content subtree never + // mounted into controller.view (blank Detail/Modal). The + // applyHostHandles setters (~5966-5986) are identity-guarded, + // so unconditional application does NOT create a re-render + // loop. This is NOT the Lever 2 update-gating path (that stays + // gated on nativeRevisionAdvanced above, ~6472-6495). + applyHostHandles(handles); + }) .catch((reason) => { setError( reason instanceof Error ? reason : new Error(String(reason)), @@ -3072,12 +6321,21 @@ function defineUIKitHost( } runOnUI( - (nextProps, fallbackPreviousProps) => { + (nextProps, fallbackPreviousProps, nextPropsRevision) => { const host = ensureRegisteredUIKitHost(hostId); if (!host) { return; } + if ( + !shouldApplyUIKitHostPropsRevision( + host.propsRevision, + nextPropsRevision, + ) + ) { + return; + } host.propsRef.current = nextProps; + host.propsRevision = nextPropsRevision ?? host.propsRevision; updateHost?.( host.nativeView, nextProps, @@ -3088,6 +6346,7 @@ function defineUIKitHost( }, currentProps, previousProps, + reactHostPropsRevision, ).catch((reason) => { setError(reason instanceof Error ? reason : new Error(String(reason))); }); @@ -3096,7 +6355,7 @@ function defineUIKitHost( hostId, mountThroughNativeHost, nativeViewHandle, - pluginProps, + reactHostPropsRevision, updateHost, ]); @@ -3148,28 +6407,75 @@ function defineUIKitHost( const { children, ...nativePropsWithoutChildren } = nativeProps as ViewProps & { children?: React.ReactNode }; - return React.createElement(NativeScriptUIViewNativeComponent, { - ...nativePropsWithoutChildren, - collapsable: false, - children, - childrenViewHandle, - controllerHandle: attachController ? controllerHandle : undefined, - detachControllerView: - attachController && !attachControllerView ? true : undefined, - debugName, - hostReadyId: hostId, - hostId: mountThroughNativeHost ? hostId : undefined, - mountedRevision: - mountThroughNativeHost && mountedHost != null && nativeHostRevision > 0 - ? nativeHostRevision + return React.createElement( + NativeScriptUIViewNativeComponent, + { + ...nativePropsWithoutChildren, + ref: nativeComponentRef, + collapsable: false, + collapsableChildren: false, + nativeID: nativeProps.nativeID ?? hostId, + pointerEvents: nativeProps.pointerEvents ?? "box-none", + childrenViewHandle, + controllerHandle: attachController ? controllerHandle : undefined, + adoptHostViewAsControllerView: adoptHostViewAsControllerView + ? true : undefined, - nativeViewHandle: attachNativeView ? nativeViewHandle : undefined, - style: layoutStyle ? [nativeProps.style, layoutStyle] : nativeProps.style, - updateRevision: - mountThroughNativeHost && nativeHostRevision > 0 - ? nativeHostRevision + attachNativeView, + attachControllerToParent: attachController + ? attachControllerToParent : undefined, - }); + collectChildren, + detachControllerFromParent: + attachController && detachControllerFromParent ? true : undefined, + detachControllerView: + attachController && !attachControllerView ? true : undefined, + disableDetachedChildrenTouchHandler, + disableUIKitHostWindowAttachRefresh, + emitOffWindowHostReady, + ignoreHostReadyWindowAttachment, + externalDetachedChildrenOwner, + fabricLifecycleCallbacks: hasFabricLifecycleCallbacks + ? true + : props.fabricLifecycleCallbacks === true + ? true + : undefined, + immediateTransactionCommit: + props.immediateTransactionCommit === true ? true : undefined, + deferTransactionCommitOnRemovals: + props.deferTransactionCommitOnRemovals === true ? true : undefined, + mountChildrenDirectlyToChildrenView, + layoutDirectChildrenToChildrenViewBounds, + pinNativeViewToHost, + preserveDetachedChildrenLayout, + detachedChildrenContentOffsetX, + detachedChildrenContentOffsetY, + debugName, + hostReadyId: hostId, + hostId: mountThroughNativeHost ? hostId : undefined, + mountedRevision: + mountThroughNativeHost && + mountedHost != null && + nativeHostRevision > 0 + ? nativeHostRevision + : undefined, + nativeViewHandle, + style: layoutStyle + ? [nativeProps.style, layoutStyle] + : nativeProps.style, + uikitHostPropsJson: + mountThroughNativeHost && nativeHostPropsJson != null + ? nativeHostPropsJson + : undefined, + uikitHostPropsRevision: mountThroughNativeHost + ? nativeHostPropsRevision + : undefined, + updateRevision: mountThroughNativeHost + ? nativeHostPropsRevision + : undefined, + }, + children, + ); }); Component.displayName = @@ -3177,12 +6483,21 @@ function defineUIKitHost( return Component; } +/** + * Wrap a single native `UIView` as a React component. `create` returns the view; + * lifecycle hooks and `ctx` run on the UI runtime. RN view props go to the host, + * your props to the definition. See {@link UIKitViewDefinition}. + */ export function defineUIKitView( definition: UIKitViewDefinition, ): UIKitViewComponent { return defineUIKitHost(definition); } +/** + * Wrap a native `UIView` that hosts RN children. `create` returns + * `{ rootView, childrenView }`; React Native children mount into `childrenView`. + */ export function defineUIKitContainer< Props extends object, RootView = unknown, @@ -3207,6 +6522,11 @@ export function defineUIKitContainer< >); } +/** + * Wrap a real `UIViewController` for APIs that need view-controller containment + * (tabs, navigation, split views, presentations). `createController` returns the + * controller; the engine handles child-controller attachment. + */ export function defineUIViewController< Props extends object, Controller = unknown, @@ -3232,34 +6552,29 @@ export function defineUIViewController< const NativeScript = { init, - install, - installGlobals, - isInstalled, - defaultMetadataPath, - defineUIKitContainer, defineUIKitView, + defineUIKitContainer, defineUIViewController, - getRuntimeBackend, - installWorklets, - assertUIKitThread, createDelegate, - createEventBridge, - createRetainer, - eventBridge, + runOnUI, + registerUIRuntimeGlobal, + dispatchAsyncOnMainQueue, + nativeMethodPolicy, getClass, - getProtocol, isClassAvailable, - isFrameworkLoaded, - isMainThread, - jsInvoker, loadFramework, - release, - retain, + nativeHandleForObject, + nativeObjectFromHandle, + invokeObjCSelector, + nativeArrayLength, + nativeArrayItem, + nativeSubviews, + collectedUIKitHostChildren, + uikitHostHandlesForView, refreshUIKitHostView, - runOnUI, - runtimeInvoker, - uiInvoker, - warnIfNotUIKitThread, + notifyUIKitAccessibilityLayoutChanged, + reactNativeFabricViewLayoutTraits, + reactNativeFabricViewLayoutTraitsForHandle, }; export default NativeScript; diff --git a/packages/react-native/test/callback-thread-policy.test.js b/packages/react-native/test/callback-thread-policy.test.js index 8c88b9ae8..4c93464ab 100644 --- a/packages/react-native/test/callback-thread-policy.test.js +++ b/packages/react-native/test/callback-thread-policy.test.js @@ -4,15 +4,19 @@ const path = require("path"); const repoRoot = path.resolve(__dirname, "../../.."); const callbackSourcePaths = [ - "packages/react-native/native-api/ffi/shared/bridge/Callbacks.mm", - "NativeScript/ffi/shared/bridge/Callbacks.mm", + "packages/react-native/native-api/ffi/objc/shared/bridge/Callbacks.mm", + "NativeScript/ffi/objc/shared/bridge/Callbacks.mm", ]; for (const relativePath of callbackSourcePaths) { - const callbacksSource = fs.readFileSync( - path.join(repoRoot, relativePath), - "utf8", - ); + const fullPath = path.join(repoRoot, relativePath); + if (!fs.existsSync(fullPath)) { + // packages/react-native/native-api is a gitignored build artifact + // produced by `npm run build-rn-turbomodule`; skip it when it hasn't + // been generated (e.g. a fresh checkout). + continue; + } + const callbacksSource = fs.readFileSync(fullPath, "utf8"); const nativeCallerPolicyIndex = callbacksSource.indexOf( "if (nativeCallerThreadCallbacks && !currentThreadIsJs)", diff --git a/packages/react-native/test/config-plugin.test.js b/packages/react-native/test/config-plugin.test.js index 3346c0f61..e4762cda0 100644 --- a/packages/react-native/test/config-plugin.test.js +++ b/packages/react-native/test/config-plugin.test.js @@ -7,6 +7,7 @@ const { ensureMetadataConfig, normalizeMetadataOptions, } = require('../plugin/withNativeScriptReactNative'); +const packageJson = require('../package.json'); function withTempProject(callback) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ns-rn-plugin-')); @@ -98,4 +99,10 @@ withTempProject((projectRoot) => { }); }); +assert.strictEqual( + packageJson.codegenConfig.ios.modulesProvider.WorkletsModule, + 'WorkletsModule', + 'NativeScript RN must publish the Worklets TurboModule provider so runOnUI can initialize after app codegen', +); + console.log('config plugin tests passed'); diff --git a/packages/react-native/test/interop-associated-object-api.test.js b/packages/react-native/test/interop-associated-object-api.test.js new file mode 100644 index 000000000..f35456769 --- /dev/null +++ b/packages/react-native/test/interop-associated-object-api.test.js @@ -0,0 +1,77 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const repoRoot = path.resolve(__dirname, "../../.."); +const packageRoot = path.resolve(__dirname, ".."); + +for (const relativePath of [ + "packages/react-native/native-api/ffi/objc/shared/bridge/TypeConv.mm", + "NativeScript/ffi/objc/shared/bridge/TypeConv.mm", +]) { + const fullPath = path.join(repoRoot, relativePath); + if (!fs.existsSync(fullPath)) { + // packages/react-native/native-api is a gitignored build artifact + // produced by `npm run build-rn-turbomodule`; skip it when it hasn't + // been generated (e.g. a fresh checkout). + continue; + } + const source = fs.readFileSync(fullPath, "utf8"); + assert( + source.includes('PropNameID::forAscii(runtime, "setAssociatedObject")') && + source.includes('PropNameID::forAscii(runtime, "getAssociatedObject")'), + `${relativePath} should expose Objective-C associated objects on generic interop`, + ); + assert( + source.includes("objc_setAssociatedObject(target, sel_registerName(key.c_str())") && + source.includes("objc_getAssociatedObject(target, sel_registerName(key.c_str()))"), + `${relativePath} should store associated objects by stable native selector keys`, + ); + const setAssociatedObjectSource = source.slice( + source.indexOf('PropNameID::forAscii(runtime, "setAssociatedObject")'), + source.indexOf('PropNameID::forAscii(runtime, "getAssociatedObject")'), + ); + assert( + setAssociatedObjectSource.indexOf("NativeApiArgumentFrame frame(1);") < + setAssociatedObjectSource.indexOf( + "value = objectFromEngineValue(runtime, bridge, args[2], frame, false);", + ) && + setAssociatedObjectSource.indexOf("NativeApiArgumentFrame frame(1);") < + setAssociatedObjectSource.indexOf("objc_setAssociatedObject("), + `${relativePath} should keep converted associated-object values alive until objc_setAssociatedObject returns`, + ); + assert( + source.includes('policy == "assign"') && + source.includes("OBJC_ASSOCIATION_ASSIGN") && + source.includes("OBJC_ASSOCIATION_RETAIN_NONATOMIC"), + `${relativePath} should expose assign and retain policies for native ownership parity`, + ); + assert( + source.includes("nativeAssociatedObjectTargetFromValue") && + source.includes("nativeObjectReturnTypeForClass(object_getClass(associated))") && + source.includes("convertNativeReturnValue(runtime, bridge, type, &associated)"), + `${relativePath} should bridge associated object values through normal NativeScript object conversion`, + ); +} + +for (const relativePath of [ + "types/objc-node-api/index.d.ts", + "../objc-node-api/index.d.ts", +]) { + const declPath = path.join(packageRoot, relativePath); + if (!fs.existsSync(declPath)) { + // packages/react-native/types is a gitignored, generated mirror of + // packages/objc-node-api's declarations; skip it when it hasn't been + // generated (e.g. a fresh checkout). + continue; + } + const declarations = fs.readFileSync(declPath, "utf8"); + assert( + declarations.includes("type AssociationPolicy") && + declarations.includes("function setAssociatedObject") && + declarations.includes("function getAssociatedObject"), + `${relativePath} should type generic associated-object interop`, + ); +} + +console.log("interop associated object API tests passed"); diff --git a/packages/react-native/test/interop-object-api.test.js b/packages/react-native/test/interop-object-api.test.js index 1db1b3859..54f1d65ea 100644 --- a/packages/react-native/test/interop-object-api.test.js +++ b/packages/react-native/test/interop-object-api.test.js @@ -6,10 +6,17 @@ const repoRoot = path.resolve(__dirname, "../../.."); const packageRoot = path.resolve(__dirname, ".."); for (const relativePath of [ - "packages/react-native/native-api/ffi/shared/bridge/TypeConv.mm", - "NativeScript/ffi/shared/bridge/TypeConv.mm", + "packages/react-native/native-api/ffi/objc/shared/bridge/TypeConv.mm", + "NativeScript/ffi/objc/shared/bridge/TypeConv.mm", ]) { - const source = fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); + const fullPath = path.join(repoRoot, relativePath); + if (!fs.existsSync(fullPath)) { + // packages/react-native/native-api is a gitignored build artifact + // produced by `npm run build-rn-turbomodule`; skip it when it hasn't + // been generated (e.g. a fresh checkout). + continue; + } + const source = fs.readFileSync(fullPath, "utf8"); assert( source.includes('PropNameID::forAscii(runtime, "object")'), `${relativePath} should expose interop.object`, @@ -27,14 +34,14 @@ for (const relativePath of [ ); } -const declarations = fs.readFileSync( - path.join(packageRoot, "types/objc-node-api/index.d.ts"), - "utf8", -); -assert( - declarations.includes("function object(handle);"), + "public runtime API should convert native handle strings back to NativeScript objects by preserving string pointer handles before numeric fallback", +); + +assert( + index.includes("export function nativeHandleForObject") && + index.includes("return nativeHandleForNSObject(value);"), + "public runtime API should expose object-to-handle conversion for UI worklets", +); + +assert( + index.includes("type EncodedObjCSelectorArgument") && + index.includes("function encodeObjCSelectorArgument") && + index.includes("Array.isArray(arg)") && + index.includes("encodedItems.push(encodedItem.value)") && + declarations.includes("readonly ObjCSelectorArgument[]"), + "public runtime API should encode nested ObjC selector arguments such as arrays of native objects", +); + +assert( + index.includes("export function nativeArrayLength") && + index.includes("export function nativeArrayItem") && + index.includes('typeof count === "function"') && + index.includes("count.call(value)") && + index.includes('invokeObjCSelector(value, "count")') && + index.includes("objectAtIndex") && + index.includes('invokeObjCSelector(value, "objectAtIndex:", [') && + index.includes("objectAtIndexedSubscript"), + "public runtime API should read bridged native arrays without assuming JS array shape", +); + +assert( + index.includes("export function nativeSubviews") && + index.includes("const subviews =") && + index.includes("nativeArrayItem(subviews, index)"), + "public runtime API should snapshot UIView subviews from the UI runtime", +); + +assert( + index.includes("function setAssociatedNativeObject") && + !index.includes("export function setAssociatedNativeObject") && + !index.includes("getAssociatedNativeObject") && + index.includes("setAssociatedObject(target, key, value ?? null, policy)"), + "associated-object writes should survive as an internal helper (public setAssociatedNativeObject/getAssociatedNativeObject removed as unused surface)", +); + +assert( + index.includes("function registerUIRuntimeGlobalOnUI") && + index.includes("export function registerUIRuntimeGlobal") && + index.includes("return runOnUI(registerUIRuntimeGlobalOnUI, name, value, force);") && + !index.includes("registerUIRuntimeGlobalSync"), + "public runtime API should register shared UI worklet globals from the React Native runtime (unused sync variant removed)", +); + +for (const name of [ + "nativeObjectFromHandle", + "nativeHandleForObject", + "nativeArrayLength", + "nativeArrayItem", + "nativeSubviews", + "registerUIRuntimeGlobal", +]) { + assert( + declarations.includes(`function ${name}`) && + index.includes(`${name},`), + `${name} should be exported from declarations and default NativeScript object`, + ); +} + +assert( + declarations.includes("export type NativeAssociationPolicy"), + "public declarations should type associated-object policy names", +); + +console.log("native object runtime API tests passed"); diff --git a/packages/react-native/test/podspec-metadata-pruning.test.js b/packages/react-native/test/podspec-metadata-pruning.test.js new file mode 100644 index 000000000..79c2ad982 --- /dev/null +++ b/packages/react-native/test/podspec-metadata-pruning.test.js @@ -0,0 +1,30 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const packageRoot = path.resolve(__dirname, ".."); +const podspec = fs.readFileSync(path.join(packageRoot, "NativeScriptNativeApi.podspec"), "utf8"); + +assert( + podspec.includes(':name => "Prune NativeScript metadata resources"') && + podspec.includes(":execution_position => :after_compile") && + podspec.includes('bundle="${BUILT_PRODUCTS_DIR}/NativeScriptNativeApi.bundle"'), + "NativeScriptNativeApi podspec should install a build phase that prunes generated metadata resources", +); + +assert( + podspec.includes("metadata.ios.arm64.nsmd") && + podspec.includes("metadata.ios-sim.$arch.nsmd") && + podspec.includes('case "$PLATFORM_NAME" in') && + podspec.includes("iphoneos)") && + podspec.includes("iphonesimulator)"), + "NativeScriptNativeApi metadata pruning should keep only the metadata file needed for the current SDK platform", +); + +assert( + podspec.includes('rm -f "$file"') && + podspec.includes('for file in "$bundle"/metadata*.nsmd; do'), + "NativeScriptNativeApi metadata pruning should remove unused metadata files from the built resource bundle", +); + +console.log("podspec metadata pruning tests passed"); diff --git a/packages/react-native/test/react-native-fabric-layout-traits-api.test.js b/packages/react-native/test/react-native-fabric-layout-traits-api.test.js new file mode 100644 index 000000000..d00fd3b5b --- /dev/null +++ b/packages/react-native/test/react-native-fabric-layout-traits-api.test.js @@ -0,0 +1,64 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const packageRoot = path.resolve(__dirname, ".."); + +function read(relativePath) { + return fs.readFileSync(path.join(packageRoot, relativePath), "utf8"); +} + +const index = read("src/index.ts"); +const declarations = read("src/index.ts"); +const nativeModule = read("ios/NativeScriptNativeApiModule.mm"); + +assert( + nativeModule.includes("__nativeScriptReactFabricViewLayoutTraits") && + nativeModule.includes("RCTComponentViewProtocol") && + nativeModule.includes("YogaStylableProps") && + nativeModule.includes("layoutMetricsForFabricComponentView") && + nativeModule.includes("classHierarchyHasInstanceVariable") && + nativeModule.includes("hasConcreteFabricStorage") && + nativeModule.includes("layoutMetrics->frame") && + nativeModule.includes("layoutMetrics->getContentFrame()") && + nativeModule.includes("yogaStyle.flexGrow()") && + nativeModule.includes("yogaStyle.flexShrink()"), + "worklet runtime should expose generic Fabric view layout traits", +); + +assert( + nativeModule.indexOf("const bool hasPropsStorage") < + nativeModule.indexOf("auto props = [componentView props]") && + nativeModule.includes("if (!hasPropsStorage) {\n return traits;\n }"), + "Fabric view traits must not call UIView(ComponentViewProtocol).props on plain UIKit views", +); + +assert( + index.includes("export type ReactNativeFabricViewLayoutTraits") && + index.includes("export function reactNativeFabricViewLayoutTraits") && + index.includes("export function reactNativeFabricViewLayoutTraitsForHandle") && + index.includes("__nativeScriptReactFabricViewLayoutTraits"), + "public JS API should expose Fabric view layout traits from objects and handles", +); + +assert( + declarations.includes("export type ReactNativeFabricViewLayoutTraits") && + declarations.includes("hasLayoutMetrics: boolean") && + declarations.includes("layoutMetricsFrameWidth?: number") && + declarations.includes("layoutMetricsContentFrameHeight?: number") && + declarations.includes("reactNativeFabricViewLayoutTraits(") && + declarations.includes("reactNativeFabricViewLayoutTraitsForHandle("), + "public declarations should type Fabric view layout traits", +); + +for (const name of [ + "reactNativeFabricViewLayoutTraits", + "reactNativeFabricViewLayoutTraitsForHandle", +]) { + assert( + index.includes(`${name},`) && index.includes(`export function ${name}`), + `${name} should be a named export listed on the default NativeScript object`, + ); +} + +console.log("react native fabric layout traits api tests passed"); diff --git a/packages/react-native/test/react-native-image-loader-api.test.js b/packages/react-native/test/react-native-image-loader-api.test.js index 72c89885b..76db708bb 100644 --- a/packages/react-native/test/react-native-image-loader-api.test.js +++ b/packages/react-native/test/react-native-image-loader-api.test.js @@ -34,7 +34,7 @@ assert( ); const declarations = fs.readFileSync( - path.join(packageRoot, "src/index.d.ts"), + path.join(packageRoot, "src/index.ts"), "utf8", ); assert( diff --git a/packages/react-native/test/runtime-callback-policy.test.js b/packages/react-native/test/runtime-callback-policy.test.js index 2536921d0..a1d535be5 100644 --- a/packages/react-native/test/runtime-callback-policy.test.js +++ b/packages/react-native/test/runtime-callback-policy.test.js @@ -19,8 +19,27 @@ assert( "public JS API should expose a generic runtime callback thread policy", ); assert( - index.includes("export function runtimeInvoker"), - "public JS API should export runtimeInvoker", + index.includes("function runtimeInvoker") && + !index.includes("export function runtimeInvoker"), + "runtimeInvoker should remain an internal callback primitive (no longer a public export)", +); +assert( + index.includes("export function dispatchAsyncOnMainQueue") && + index.includes("__nativeScriptDispatchAsyncOnMainQueue") && + index.includes('"NativeScript.dispatchAsyncOnMainQueue expects a callback"'), + "public JS API should expose a generic UI-runtime main-queue async scheduler", +); +assert( + index.includes("const NativeScript = {") && + index.includes(" dispatchAsyncOnMainQueue,\n") && + index.indexOf(" dispatchAsyncOnMainQueue,\n") > + index.indexOf("const NativeScript = {"), + "default NativeScript export should include the generic main-queue async scheduler", +); +assert( + index.includes("export function nativeMethodPolicy") && + index.includes("__nativeScriptMethodPolicy"), + "public JS API should expose generic native method callback policy markers", ); assert( !index.includes("export function objCBlock") && @@ -51,14 +70,41 @@ assert( "runtime should not expose a transition-specific callback API", ); -const declarations = readPackage("src/index.d.ts"); +const declarations = readPackage("src/index.ts"); assert( declarations.includes('NativeScriptCallbackThread = "js" | "runtime"'), "public declarations should include the runtime callback policy", ); assert( - declarations.includes("runtimeInvoker void): boolean"), + "public declarations should expose the generic main-queue async scheduler", +); +assert( + declarations.includes(" dispatchAsyncOnMainQueue,"), + "public default declarations should include the generic main-queue async scheduler", +); +// The method-policy DSL is intentionally trimmed to its two live fields: +// callSuperBeforeCallback and skipCallbackIfAssociatedObjectTruthy. The +// fuller DSL (argument-index targets, associated-object condition/ +// comparison trees, keyPath assignments, typed skip-return values) had no +// caller anywhere in the fork or its own pin tests advertising it. +assert( + declarations.includes("NativeScriptMethodCallbackPolicy") && + declarations.includes("nativeMethodPolicyschedule"), "runtime callbacks should schedule work onto the Worklet runtime", ); +assert( + moduleSource.includes("__nativeScriptDispatchAsyncOnMainQueue") && + moduleSource.includes("dispatch_async(dispatch_get_main_queue(), ^{") && + moduleSource.includes("nativeScriptWorkletRuntimeCallbacksAllowed(workletRuntimeGeneration)") && + moduleSource.includes("callback->call(runtime);"), + "Worklet runtime install should expose a generation-gated main-queue async callback scheduler", +); +assert( + moduleSource.includes("logNativeScriptWorkletRuntimeException") && + normalizedModuleSource.includes( + 'logNativeScriptWorkletRuntimeException( "runtimeCallbackInvoker", error)', + ) && + normalizedModuleSource.includes( + 'logNativeScriptWorkletRuntimeException( "dispatchAsyncOnMainQueue", error)', + ), + "Worklet runtime scheduled callbacks should log exceptions instead of allowing opaque native aborts", +); +assert( + normalizedModuleSource.includes( + "config.runtimeCallbackInvoker = [workletRuntimeWeak, workletRuntimeGeneration]( std::function task) mutable { if (!nativeScriptWorkletRuntimeCallbacksAllowed(workletRuntimeGeneration)) { return; } auto runtimeStrong = workletRuntimeWeak.lock();", + ) && + !normalizedModuleSource.includes( + "dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, 2 * NSEC_PER_SEC))", + ), + "runtime callbacks must execute inline via runSync on the caller thread — the schedule-and-timed-wait design dropped callbacks under contention (dismissal bookkeeping) and serialized UIKit delegate bursts into multi-second freezes", +); +assert( + moduleSource.indexOf("__nativeScriptDispatchAsyncOnMainQueue") < + moduleSource.indexOf("__nativeScriptRefreshUIKitHostView"), + "main-queue scheduler should be installed before UIKit host worklet globals use it", +); +assert( + moduleSource.includes("config.callbackInvocationAllowed"), + "Worklet runtime install should gate native callbacks during runtime invalidation", +); +assert( + moduleSource.includes("config.installGlobalSymbols = false") && + !moduleSource.includes("config.installGlobalSymbols = true"), + "React Native and Worklet installs should keep NativeScript globals opt-in", +); +{ + const rnJsiConfig = readRepo("NativeScript/ffi/objc/hermes/NativeApiJsiReactNative.h"); + const backendConfig = readRepo( + "NativeScript/ffi/objc/shared/NativeApiBackendConfig.h", + ); + const bridgeSource = readRepo("NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm"); + assert( + backendConfig.includes("bool indexRuntimePointers = true") && + rnJsiConfig.includes("config.indexRuntimePointers = false") && + bridgeSource.includes("indexRuntimePointers_(config.indexRuntimePointers)") && + bridgeSource.includes("if (indexRuntimePointers_) {\n Class cls = objc_lookUpClass") && + bridgeSource.includes("if (indexRuntimePointers_) {\n Protocol* protocol = lookupProtocolByNativeName"), + "React Native Native API installs should avoid eagerly resolving every runtime class/protocol pointer", + ); +} +assert( + moduleSource.includes("RCTBridgeWillInvalidateModulesNotification"), + "Native module should stop runtime callbacks before React Native invalidates modules", +); +assert( + moduleSource.includes("nativeScriptWorkletRuntimeGeneration"), + "Native module should generation-gate callbacks so stale runtimes cannot resume after reload", +); +assert( + normalizedModuleSource.includes( + "runtimeStrong->runSync( [&task, workletRuntimeGeneration](jsi::Runtime&) { if (nativeScriptWorkletRuntimeCallbacksAllowed( workletRuntimeGeneration)) { task(); } })", + ), + "runtime callbacks must re-check the runtime generation inside runSync so stale runtimes cannot execute callbacks after reload", +); assert( !moduleSource.includes("__nativeScriptAfterUIKitTransition"), "Native module should not install transition-specific host functions", ); +{ + const callbackSource = readRepo("NativeScript/ffi/objc/shared/bridge/Callbacks.mm"); + const classBuilderSource = readRepo( + "NativeScript/ffi/objc/shared/bridge/ClassBuilder.mm", + ); + // The method-policy DSL is intentionally trimmed to its two live fields: + // callSuperBeforeCallback and skipCallbackIfAssociatedObjectTruthy (read + // from a JS function's __nativeScriptMethodPolicy expando). The fuller DSL + // below (argument-index targets, associated-object condition/comparison + // trees, keyPath assignments, typed skip-return values, and class-level + // ObjCMethodPolicies/methodPolicies plumbing) had no caller anywhere in + // the fork or its own pin tests advertising it, so it's cut along with + // the matching runtime surface. + assert( + callbackSource.includes("__nativeScriptMethodPolicy") && + callbackSource.includes("callSuperBeforeCallback") && + callbackSource.includes("skipCallbackIfAssociatedObjectTruthy"), + "native callbacks should parse the trimmed native method policy", + ); + assert( + !callbackSource.includes("skipCallbackIfAllAssociatedObjectConditions") && + !callbackSource.includes("setAssociatedObjectsBeforeSkip") && + !callbackSource.includes("setKeyPathValuesBeforeSkip") && + !callbackSource.includes("returnValueIfSkipped") && + !callbackSource.includes("objectForMethodPolicyTarget") && + !callbackSource.includes("TargetKind::Argument") && + !callbackSource.includes("associatedObjectsAreEqual") && + !callbackSource.includes("storePrimitivePolicyReturnValue"), + "native callbacks should not carry the dead method-policy DSL (no caller ever used it)", + ); + assert( + callbackSource.includes("invokeMethodSuper(ret, args)") && + callbackSource.includes("shouldSkipMethodCallback(args, ret)") && + callbackSource.indexOf("invokeMethodSuper(ret, args)") < + callbackSource.indexOf("invokeOnCurrentThread(ret, args"), + "method policy should call native super and skip before JS argument conversion", + ); + assert( + callbackSource.includes("methodCallbackReceiver(args)") && + callbackSource.includes("associatedObjectIsTruthy") && + callbackSource.includes("shouldSkipConstructingMethodCallback"), + "method policy should check the receiver's associated-object skip key and the construction-state re-entry guard", + ); + assert( + callbackSource.includes("Class superDispatchClass = methodBaseClass_;") && + callbackSource.includes("makeNativeObjectValue(\n *runtime_, bridge_, self, false, superDispatchClass)"), + "callback-bound this.super should dispatch from the lexical override superclass " + + "(methodBaseClass_ IS the override's base class already -- must be used directly, " + + "not further superclassed, or a member declared exactly on it becomes unreachable via this.super)", + ); + assert( + classBuilderSource.includes("returnOwned, baseClass"), + "class overrides should pass their base class to method callback policy", + ); + assert( + !classBuilderSource.includes('options.methodPolicies') && + !classBuilderSource.includes('options.nativeMethodPolicies') && + !classBuilderSource.includes("methodCallbackPolicyForSelector"), + "class extension options should not plumb class-level method policies (no caller ever set them; only the per-function nativeMethodPolicy() expando path is live)", + ); + assert( + classBuilderSource.includes("nativeAccessorCallbackPolicy(") && + classBuilderSource.includes("skipCallbackIfAssociatedObjectTruthy.push_back(\n \"__nativeApiAccessorCallbackState\")"), + "native accessor (getter/setter) overrides should auto-apply the accessor re-entry guard policy", + ); + assert( + classBuilderSource.includes("methodBaseClass") && + classBuilderSource.includes("std::move(methodPolicy)") && + classBuilderSource.includes("addEngineExposedMethod(runtime, bridge, nativeClass, selectorName"), + "explicit exposed method overrides should also receive base class and method policy plumbing", + ); + for (const relativePath of [ + "packages/react-native/native-api/ffi/objc/shared/bridge/Install.mm", + "NativeScript/ffi/objc/shared/bridge/Install.mm", + ]) { + if (!fs.existsSync(path.join(repoRoot, relativePath))) { + continue; + } + const installSource = readRepo(relativePath); + assert( + !installSource.includes("constructor.ObjCMethodPolicies") && + !installSource.includes("options.methodPolicies = constructor.ObjCMethodPolicies"), + `${relativePath} should not pass a class-level ObjCMethodPolicies map into __extendClass (dead DSL)`, + ); + } + for (const relativePath of [ + "packages/react-native/native-api/ffi/objc/shared/bridge/host_objects/Object.mm", + "NativeScript/ffi/objc/shared/bridge/host_objects/Object.mm", + ]) { + if (!fs.existsSync(path.join(repoRoot, relativePath))) { + continue; + } + const hostObjectSource = readRepo(relativePath); + assert( + hostObjectSource.includes("Class superDispatchClass_ = Nil") && + hostObjectSource.includes("void setSuperDispatchClass(Class superDispatchClass) {") && + hostObjectSource.includes("superDispatchClass_ != Nil") && + hostObjectSource.includes("std::make_shared"), + `${relativePath} should let callback-bound object wrappers override super dispatch class`, + ); + } +} + for (const relativePath of [ - "packages/react-native/native-api/ffi/shared/NativeApiBackendConfig.h", - "NativeScript/ffi/shared/NativeApiBackendConfig.h", + "packages/react-native/native-api/ffi/objc/shared/NativeApiBackendConfig.h", + "NativeScript/ffi/objc/shared/NativeApiBackendConfig.h", ]) { + if (!fs.existsSync(path.join(repoRoot, relativePath))) { + continue; + } const source = readRepo(relativePath); assert( source.includes("runtimeCallbackInvoker"), `${relativePath} should expose a generic runtime callback invoker`, ); + assert( + source.includes("callbackInvocationAllowed"), + `${relativePath} should expose a generic callback invocation gate`, + ); } for (const relativePath of [ - "packages/react-native/native-api/ffi/shared/bridge/Callbacks.mm", - "NativeScript/ffi/shared/bridge/Callbacks.mm", + "packages/react-native/native-api/ffi/objc/shared/bridge/Callbacks.mm", + "NativeScript/ffi/objc/shared/bridge/Callbacks.mm", ]) { + if (!fs.existsSync(path.join(repoRoot, relativePath))) { + continue; + } const source = readRepo(relativePath); assert( source.includes("NativeApiCallbackThreadPolicy::Runtime"), @@ -112,6 +341,21 @@ for (const relativePath of [ source.includes("bridge_->runtimeCallbackInvoker()"), `${relativePath} should dispatch runtime-marked callbacks through the generic invoker`, ); + assert( + source.includes("callbackInvocationAllowed()") && + source.includes("zeroReturnValue(ret)"), + `${relativePath} should zero-return native callbacks once their runtime is invalidating`, + ); + const bridgeRelativePath = relativePath.replace("Callbacks.mm", "ObjCBridge.mm"); + const bridgeSource = readRepo(bridgeRelativePath); + assert( + bridgeSource.includes("bool callbackInvocationAllowed() const noexcept") && + bridgeSource.includes("@try") && + bridgeSource.includes("@catch (...)") && + bridgeSource.includes("catch (...)") && + bridgeSource.includes("return false;"), + `${bridgeRelativePath} should make the callback invocation gate no-throw for C++ and Objective-C exceptions`, + ); assert( source.includes("parseObjCCallbackEngineSignature") && source.includes("objcSignatureEncoding"), @@ -119,4 +363,19 @@ for (const relativePath of [ ); } +for (const relativePath of [ + "packages/react-native/native-api/ffi/objc/shared/bridge/Install.mm", + "NativeScript/ffi/objc/shared/bridge/Install.mm", +]) { + if (!fs.existsSync(path.join(repoRoot, relativePath))) { + continue; + } + const source = readRepo(relativePath); + assert( + source.includes('NativeApiWriteSmokeStage("engine:skip-globals")') && + !source.includes('InstallAggregateGlobals(runtime, api, "protocolNames")'), + `${relativePath} should not eagerly install protocol globals when globals are disabled`, + ); +} + console.log("runtime callback policy tests passed"); diff --git a/packages/react-native/test/runtime-indexed-collection-alias.test.js b/packages/react-native/test/runtime-indexed-collection-alias.test.js new file mode 100644 index 000000000..50c6b00ad --- /dev/null +++ b/packages/react-native/test/runtime-indexed-collection-alias.test.js @@ -0,0 +1,63 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const repoRoot = path.resolve(__dirname, "../../.."); + +for (const relativePath of [ + "NativeScript/ffi/objc/shared/bridge/Install.mm", + "packages/react-native/native-api/ffi/objc/shared/bridge/Install.mm", +]) { + const fullPath = path.join(repoRoot, relativePath); + if (!fs.existsSync(fullPath)) { + // packages/react-native/native-api is a gitignored build artifact + // produced by `npm run build-rn-turbomodule`; skip it when it hasn't + // been generated (e.g. a fresh checkout). + continue; + } + const source = fs.readFileSync(fullPath, "utf8"); + + assert( + source.includes("function nativeExtensionMethodsWithIndexedCollectionAliases(methods)"), + `${relativePath}: native class extension should prepare indexed collection method aliases`, + ); + assert( + source.includes("needsObjectAtIndexedSubscript") && + source.includes("needsSetObjectAtIndexedSubscript"), + `${relativePath}: indexed collection aliases should cover read and write native subscript selectors`, + ); + assert( + source.includes("return this.objectAtIndex(index);") && + source.includes("return this.replaceObjectAtIndexWithObject(index, anObject);"), + `${relativePath}: synthesized subscript aliases should delegate to the JS primitive methods with native argument order corrected`, + ); + assert( + source.includes("needsIndexedCollectionIterator") && + source.includes("Object.defineProperty(prepared, Symbol.iterator") && + source.includes("value: receiver.objectAtIndex(index++)"), + `${relativePath}: JS-backed indexed collection subclasses should synthesize Symbol.iterator from count/objectAtIndex`, + ); + assert( + source.includes("function nativeExtensionOptionsWithIterator(options, methods)") && + source.includes("nativeExtensionMethodsHaveIterator(methods)") && + source.includes("__hasIterator: true"), + `${relativePath}: prepared indexed collection iterators should enable the native fast-enumeration bridge`, + ); + assert( + source.includes("var extensionMethods = nativeExtensionMethodsWithIndexedCollectionAliases(methods);") && + source.includes("nativeExtensionOptionsWithIterator(options, extensionMethods)") && + source.includes("api.__extendClass(nativeClass, extensionMethods, extendOptions)") && + source.includes("Object.getOwnPropertyDescriptors(extensionMethods)") && + source.includes("Object.keys(extensionMethods)"), + `${relativePath}: NativeClass.extend should register and expose the prepared indexed collection method set`, + ); + assert( + source.includes("nativeExtensionMethodsWithIndexedCollectionAliases(constructor.prototype || {})") && + source.includes("options = nativeExtensionOptionsWithIterator(options, extensionMethods)") && + source.includes("api.__extendClass(nativeBase, extensionMethods, options)") && + source.includes("api.__rememberClassWrapper(nativeClass, constructor, extensionMethods)"), + `${relativePath}: TypeScript native class materialization should use the same prepared method set`, + ); +} + +console.log("runtime indexed collection alias tests passed"); diff --git a/packages/react-native/test/runtime-instance-selector-base-dispatch.test.js b/packages/react-native/test/runtime-instance-selector-base-dispatch.test.js new file mode 100644 index 000000000..df85dd2ce --- /dev/null +++ b/packages/react-native/test/runtime-instance-selector-base-dispatch.test.js @@ -0,0 +1,393 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const repoRoot = path.resolve(__dirname, "../../.."); + +// refactor split the old monolithic HostObjects.mm into host_objects/*.mm, +// included (in this order) by the residual HostObjects.mm. Concatenate them +// back into one logical blob so the substring/ordering assertions below +// (carried over from when this was one file) still hold. +const HOST_OBJECTS_INCLUDE_ORDER = [ + "Interop.mm", + "Struct.mm", + "Appearance.mm", + "Object.mm", + "Class.mm", + "Protocol.mm", +]; + +function readLogicalHostObjects(bridgeDir) { + const residualPath = path.join(bridgeDir, "HostObjects.mm"); + if (!fs.existsSync(residualPath)) { + return null; + } + const parts = [fs.readFileSync(residualPath, "utf8")]; + for (const name of HOST_OBJECTS_INCLUDE_ORDER) { + parts.push(fs.readFileSync(path.join(bridgeDir, "host_objects", name), "utf8")); + } + return parts.join("\n"); +} + +for (const relativePath of [ + "NativeScript/ffi/objc/shared/bridge/Install.mm", + "packages/react-native/native-api/ffi/objc/shared/bridge/Install.mm", +]) { + const fullPath = path.join(repoRoot, relativePath); + if (!fs.existsSync(fullPath)) { + // packages/react-native/native-api is a gitignored build artifact + // produced by `npm run build-rn-turbomodule`; skip it when it hasn't + // been generated (e.g. a fresh checkout). + continue; + } + const source = fs.readFileSync(fullPath, "utf8"); + + const invokeBaseCount = source.match(/return api\.__invokeBase\(\.\.\.baseArgs\);/g)?.length ?? 0; + assert( + invokeBaseCount === 1, + `${relativePath}: only class selector wrappers should route native receivers through __invokeBase`, + ); + assert( + !source.includes("return fn.apply(this, arguments);"), + `${relativePath}: instance selector wrappers should not globally reroute native receivers through __invokeBase`, + ); + assert( + source.includes("value: receiverIsClass") && + source.includes("? (function(fn, memberName) {") && + source.includes("var baseArgs = [nativeClass, this, memberName];") && + source.includes(": selectorFunction"), + `${relativePath}: class selector wrappers should keep base invocation support while instance selectors use engine dispatch`, + ); + const allocInitFlagDefinitions = + source.match(/Object\.defineProperty\([^,]+, '__nativeApiUseAllocInitConstructor'/g) || []; + assert( + source.includes("function shouldUseAllocInitConstructor(constructable, wrapper)") && + source.includes("function setObjectConstructionState(instance, constructing)") && + source.includes("api.__setObjectConstructionState(instance, !!constructing)") && + source.includes("target.__nativeApiUseAllocInitConstructor") && + source.includes("args.length > 0 ||") && + source.includes("shouldUseAllocInitConstructor(constructable, wrapper)") && + source.includes("setObjectConstructionState(instance, true)") && + source.includes("setObjectConstructionState(instance, false)") && + allocInitFlagDefinitions.length >= 2, + `${relativePath}: JS-extended native classes should use alloc/init construction so receivers are remembered before init dispatch`, + ); +} + +for (const relativePath of [ + "NativeScript/ffi/objc/shared/bridge/HostObject.mm", + "packages/react-native/native-api/ffi/objc/shared/bridge/HostObject.mm", +]) { + const fullPath = path.join(repoRoot, relativePath); + if (!fs.existsSync(fullPath)) { + continue; + } + const source = fs.readFileSync(fullPath, "utf8"); + + assert( + source.includes("__setObjectConstructionState") && + source.includes('sel_registerName("__nativeApiConstructionState")') && + source.includes("constructing ? @YES : nil"), + `${relativePath}: bridge API should mark native objects during JS-subclass construction`, + ); +} + +for (const bridgeDir of [ + path.join(repoRoot, "NativeScript/ffi/objc/shared/bridge"), + path.join(repoRoot, "packages/react-native/native-api/ffi/objc/shared/bridge"), +]) { + const source = readLogicalHostObjects(bridgeDir); + if (source == null) { + continue; + } + const branchIndex = source.indexOf("if (isEngineExtendedInstance) {"); + const resolveIndex = source.indexOf( + "Value resolved = resolveEnginePrototypeGetter(runtime, property, &found);", + branchIndex, + ); + const nativeGetterIndex = source.indexOf( + "runtimeReadablePropertyGetter(object_, property)", + branchIndex, + ); + const guardedIndex = source.lastIndexOf( + "#ifdef NATIVESCRIPT_NATIVE_API_HOST_EXPLICIT_OVERRIDE", + resolveIndex, + ); + + assert( + branchIndex !== -1 && + resolveIndex !== -1 && + nativeGetterIndex !== -1 && + branchIndex < resolveIndex && + resolveIndex < nativeGetterIndex && + guardedIndex < branchIndex, + `${bridgeDir}: JS-subclassed property reads should prefer prototype getters before native runtime getters on every backend`, + ); + // Deduped in refactor: classPrototypeForObject (not a second + // enginePrototypeForObject function) is the shared class-wrapper-prototype + // lookup used by method/getter/setter resolution. + assert( + source.includes("Value classPrototypeForObject(Runtime& runtime)") && + source.includes("bridge_->findClassPrototype(runtime, object_getClass(object_))") && + (source.match(/Value prototypeValue = classPrototypeForObject\(runtime\);/g)?.length ?? 0) >= 3, + `${bridgeDir}: JS-subclassed prototype lookup should use the registered class prototype fallback for methods, getters, and setters`, + ); + + const nilObjectSetIndex = source.indexOf( + 'throw JSError(runtime, "Cannot set property on nil object.");', + ); + const setIndex = source.lastIndexOf( + "NativeApiHostSetResult set(", + nilObjectSetIndex, + ); + const writableIndex = source.indexOf( + "selectWritablePropertyMember(members, property, false)", + setIndex, + ); + const explicitSetterIndex = source.indexOf( + "invokeEnginePrototypeSetter(runtime, property, value)", + setIndex, + ); + + assert( + setIndex !== -1 && + nilObjectSetIndex !== -1 && + setIndex < nilObjectSetIndex && + writableIndex !== -1 && + explicitSetterIndex !== -1 && + setIndex < explicitSetterIndex && + explicitSetterIndex < writableIndex, + `${bridgeDir}: JS-subclassed property writes should try the prototype setter before native metadata/runtime setters on every backend`, + ); + // Simplification (§4-C): enginePrototypeHasSetter is gone — by the time + // set() reaches the no-native-setter fallback, the hoisted + // invokeEnginePrototypeSetter attempt above has already run and didn't + // return, so the expando is stored unconditionally instead of re-probing. + assert( + !source.includes("enginePrototypeHasSetter") && + source.includes("storeOwnExpando(runtime, property, value);\n NATIVE_API_SET_RETURN(false);"), + `${bridgeDir}: the no-prototype-setter fallback should store the expando unconditionally, not re-probe for a setter`, + ); +} + +for (const relativePath of [ + "NativeScript/ffi/objc/shared/bridge/Callbacks.mm", + "packages/react-native/native-api/ffi/objc/shared/bridge/Callbacks.mm", +]) { + const fullPath = path.join(repoRoot, relativePath); + if (!fs.existsSync(fullPath)) { + continue; + } + const source = fs.readFileSync(fullPath, "utf8"); + + assert( + source.includes("shouldSkipConstructingMethodCallback(args, ret)") && + source.includes('signature_->selectorName.rfind("init", 0) == 0') && + source.includes('sel_registerName("__nativeApiConstructionState")') && + source.includes("zeroReturnValue(ret);"), + `${relativePath}: Objective-C callbacks should zero-return non-init JS overrides while the receiver is still constructing`, + ); +} + +{ + const installSource = fs.readFileSync( + path.join(repoRoot, "NativeScript/ffi/objc/shared/bridge/Install.mm"), + "utf8", + ); + const hostObjectSource = fs.readFileSync( + path.join(repoRoot, "NativeScript/ffi/objc/shared/bridge/HostObject.mm"), + "utf8", + ); + const classBuilderSource = fs.readFileSync( + path.join(repoRoot, "NativeScript/ffi/objc/shared/bridge/ClassBuilder.mm"), + "utf8", + ); + const callbacksSource = fs.readFileSync( + path.join(repoRoot, "NativeScript/ffi/objc/shared/bridge/Callbacks.mm"), + "utf8", + ); + + assert( + installSource.includes("function setObjectAccessorCallbackState(instance, active)") && + installSource.includes("__setObjectAccessorCallbackState(instance, !!active)") && + installSource.includes("function nativeExtensionAccessorWithCallbackState(fn)") && + installSource.includes("Object.getOwnPropertyDescriptors(methods)") && + installSource.includes("Reflect.ownKeys(descriptors)") && + installSource.includes("fn.apply(this, args)") && + installSource.includes("setObjectAccessorCallbackState(this, false)"), + "JS extension accessors should run with native callback re-entry suppression state", + ); + assert( + hostObjectSource.includes("__setObjectAccessorCallbackState") && + hostObjectSource.includes('sel_registerName("__nativeApiAccessorCallbackState")') && + hostObjectSource.includes("depth += 1") && + hostObjectSource.includes("depth -= 1") && + hostObjectSource.includes("depth > 0 ? @(depth) : nil"), + "bridge API should expose a depth-counted JS accessor callback state on native objects", + ); + // methodCallbackPolicyForSelector (class-level ObjCMethodPolicies/ + // methodPolicies) is dropped (§4-A, no caller ever set them); + // nativeAccessorCallbackPolicy() is now called with no per-selector + // policy argument — it always installs just the accessor re-entry key. + assert( + classBuilderSource.includes("NativeApiMethodCallbackPolicy nativeAccessorCallbackPolicy") && + classBuilderSource.includes('"__nativeApiAccessorCallbackState"') && + !classBuilderSource.includes("methodCallbackPolicyForSelector") && + (classBuilderSource.match(/nativeAccessorCallbackPolicy\(\)/g)?.length ?? 0) >= 3, + "property accessor overrides should skip native callback re-entry while JS accessors are executing", + ); + // applyMethodPolicyAssignments (and the rest of the associated-object + // assignment/keyPath DSL) is dropped along with the trimmed policy — + // shouldSkipMethodCallback just zero-returns and reports skipped. + assert( + !callbacksSource.includes("applyMethodPolicyAssignments") && + callbacksSource.includes("zeroReturnValue(ret);\n return true;"), + "skipped native method callbacks should zero their native return storage without the dropped policy-assignment DSL", + ); +} + +for (const relativePath of [ + "NativeScript/ffi/objc/shared/bridge/ClassBuilder.mm", + "packages/react-native/native-api/ffi/objc/shared/bridge/ClassBuilder.mm", +]) { + const fullPath = path.join(repoRoot, relativePath); + if (!fs.existsSync(fullPath)) { + continue; + } + const source = fs.readFileSync(fullPath, "utf8"); + + assert( + source.includes("std::optional preservedNativeApiInitializerSelfReturn("), + `${relativePath}: runtime should expose the initializer self-preservation helper`, + ); + assert( + source.includes("receiverHostObject->object() != receiver") && + source.includes("NativeApiObjectHostObject::nativeObjectFromValue(runtime, result)") && + source.includes("resultHostObject != receiverHostObject") && + source.includes("detachObjectPreservingBridgeState(receiver)") && + source.includes("bridge->rememberNativeObjectRoundTripValue(runtime, receiver,") && + source.includes("return preserved;") && + source.includes("return std::move(*preserved);"), + `${relativePath}: initializer dispatch should preserve the original JS receiver when native init returns self`, + ); + assert( + source.includes("preservedNativeApiInitializerSelfReturn(") && + source.includes("Value(runtime, receiverObject)"), + `${relativePath}: __invokeBase should use the shared initializer self-preservation helper`, + ); +} + +for (const relativePath of [ + "NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm", + "packages/react-native/native-api/ffi/objc/shared/bridge/ObjCBridge.mm", +]) { + const fullPath = path.join(repoRoot, relativePath); + if (!fs.existsSync(fullPath)) { + continue; + } + const source = fs.readFileSync(fullPath, "utf8"); + + assert( + source.includes("void rememberNativeObjectRoundTripValue(Runtime& runtime, id object,") && + source.includes("rememberRoundTripValue(runtime, object, value, stringLikeNative,") && + source.includes("nativeObjectClassKey(object));"), + `${relativePath}: preserved native object wrappers should use the same validation key as native return marshalling`, + ); +} + +for (const relativePath of [ + "NativeScript/ffi/objc/jsc/NativeApiJSCSelectorGroups.mm", + "NativeScript/ffi/objc/v8/NativeApiV8SelectorGroups.mm", + "NativeScript/ffi/objc/quickjs/NativeApiQuickJSSelectorGroups.mm", + "NativeScript/ffi/objc/hermes/NativeApiJsi.mm", + "packages/react-native/native-api/ffi/objc/hermes/NativeApiJsi.mm", +]) { + const fullPath = path.join(repoRoot, relativePath); + if (!fs.existsSync(fullPath)) { + continue; + } + const source = fs.readFileSync(fullPath, "utf8"); + assert( + source.includes("prepared->isInitMethod") && + source.includes("preservedNativeApiInitializerSelfReturn(") && + (!relativePath.includes("/hermes/") || + source.includes("return std::move(*preserved);")), + `${relativePath}: engine selector groups should preserve the original receiver when init returns self`, + ); +} + +{ + const relativePath = "NativeScript/ffi/objc/v8/NativeApiV8HostObjects.mm"; + const source = fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); + const hostTemplateStart = source.indexOf( + "v8::Local hostObjectTemplate", + ); + const hostTemplateEnd = source.indexOf( + "state->hostObjectTemplate.Reset", + hostTemplateStart, + ); + const nativeTemplateStart = source.indexOf( + "v8::Local nativeObjectTemplate", + ); + const nativeTemplateEnd = source.indexOf( + "state->nativeObjectTemplate.Reset", + nativeTemplateStart, + ); + const hostTemplate = source.slice(hostTemplateStart, hostTemplateEnd); + const nativeTemplate = source.slice(nativeTemplateStart, nativeTemplateEnd); + const getterIndex = nativeTemplate.indexOf( + "tryResolvePrototypeGet(runtime, holderObject, receiver", + ); + const nativeGetIndex = nativeTemplate.indexOf( + "holder->hostObject->get", + getterIndex, + ); + const setterIndex = nativeTemplate.indexOf( + "tryInvokePrototypeSetter(runtime, holderObject, receiver", + ); + const nativeSetIndex = nativeTemplate.indexOf( + "holder->hostObject->set", + setterIndex, + ); + + assert( + source.includes("GetPrototypeV2") && + source.includes("GetOwnPropertyDescriptor") && + source.includes("tryResolvePrototypeGet") && + source.includes("tryInvokePrototypeSetter") && + nativeTemplate.includes("v8::PropertyHandlerFlags::kNonMasking"), + `${relativePath}: V8 native object interceptors should inspect JS prototype descriptors despite kNonMasking handlers`, + ); + assert( + getterIndex !== -1 && + nativeGetIndex !== -1 && + getterIndex < nativeGetIndex && + setterIndex !== -1 && + nativeSetIndex !== -1 && + setterIndex < nativeSetIndex, + `${relativePath}: V8 native object interceptors should honor JS prototype accessors before native host dispatch`, + ); + assert( + !hostTemplate.includes("tryResolvePrototypeGet(runtime, holderObject, receiver"), + `${relativePath}: JS prototype accessor precedence should be scoped to native object instances, not generic host objects`, + ); +} + +for (const bridgeDir of [ + path.join(repoRoot, "NativeScript/ffi/objc/shared/bridge"), + path.join(repoRoot, "packages/react-native/native-api/ffi/objc/shared/bridge"), +]) { + const source = readLogicalHostObjects(bridgeDir); + if (source == null) { + continue; + } + assert( + source.includes("void detachObjectPreservingBridgeState(id expected)") && + source.includes("if (releaseObject && object != nil)") && + source.includes("[object release];") && + !source.includes("detachObjectPreservingBridgeState(id expected) {\n if (object_ == expected) {\n if (bridge_ != nullptr"), + `${bridgeDir}: temporary initializer result wrappers should detach without clearing live receiver bridge state`, + ); +} + +console.log("runtime instance selector base dispatch tests passed"); diff --git a/packages/react-native/test/runtime-js-subclass-expando.test.js b/packages/react-native/test/runtime-js-subclass-expando.test.js new file mode 100644 index 000000000..e386ffd5a --- /dev/null +++ b/packages/react-native/test/runtime-js-subclass-expando.test.js @@ -0,0 +1,31 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const repoRoot = path.resolve(__dirname, "../../.."); + +for (const relativePath of [ + "NativeScript/ffi/objc/shared/bridge/host_objects/Object.mm", + "packages/react-native/native-api/ffi/objc/shared/bridge/host_objects/Object.mm", +]) { + const fullPath = path.join(repoRoot, relativePath); + if (!fs.existsSync(fullPath)) { + // The packages/react-native/native-api mirror is a gitignored build + // artifact produced by `npm run build-rn-turbomodule`; skip it when it + // hasn't been generated (e.g. a fresh checkout). + continue; + } + const source = fs.readFileSync(fullPath, "utf8"); + + assert( + source.includes("if (isEngineExtendedInstance) {\n if (invokeEnginePrototypeSetter(runtime, property, value)) {\n NATIVE_API_SET_RETURN(true);\n }\n }"), + `${relativePath}: a JS-subclass instance's prototype setter must be tried before any metadata/runtime setter path`, + ); + + assert( + source.includes("storeOwnExpando(runtime, property, value);\n NATIVE_API_SET_RETURN(false);"), + `${relativePath}: JS subclass expando fallback should still mirror plain JS-owned fields into native expandos when no prototype setter fired`, + ); +} + +console.log("runtime JS subclass expando tests passed"); diff --git a/packages/react-native/test/runtime-member-cache.test.js b/packages/react-native/test/runtime-member-cache.test.js index 3bedd944b..8387e2d98 100644 --- a/packages/react-native/test/runtime-member-cache.test.js +++ b/packages/react-native/test/runtime-member-cache.test.js @@ -4,12 +4,19 @@ const path = require("path"); const repoRoot = path.resolve(__dirname, "../../.."); const hostObjectSources = [ - "NativeScript/ffi/shared/bridge/HostObjects.mm", - "packages/react-native/native-api/ffi/shared/bridge/HostObjects.mm", + "NativeScript/ffi/objc/shared/bridge/host_objects/Object.mm", + "packages/react-native/native-api/ffi/objc/shared/bridge/host_objects/Object.mm", ]; for (const sourcePath of hostObjectSources) { - const hostObjects = fs.readFileSync(path.join(repoRoot, sourcePath), "utf8"); + const fullPath = path.join(repoRoot, sourcePath); + if (!fs.existsSync(fullPath)) { + // The packages/react-native/native-api mirror is a gitignored build + // artifact produced by `npm run build-rn-turbomodule`; skip it when it + // hasn't been generated (e.g. a fresh checkout). + continue; + } + const hostObjects = fs.readFileSync(fullPath, "utf8"); assert( hostObjects.includes("NativeApiRuntimeMembersCacheKey"), @@ -39,6 +46,18 @@ for (const sourcePath of hostObjectSources) { hostObjects.includes("selectorsByNameAndCount.find(name)"), `${sourcePath}: runtime selector resolution should use indexed selector lookup`, ); + // The readable-property-getter fallback cache is intentionally simplified + // to a single mutex-guarded (Class, property) -> selector map (no + // thread-local front cache) — see resolveRuntimeReadablePropertyGetter / + // runtimeReadablePropertyGetter. + assert( + hostObjects.includes("resolveRuntimeReadablePropertyGetter("), + `${sourcePath}: runtime property getter fallback should separate resolution from caching`, + ); + assert( + hostObjects.includes("cache[cls][property]"), + `${sourcePath}: runtime property getter fallback should populate the class/property cache`, + ); } console.log("runtime member cache tests passed"); diff --git a/packages/react-native/test/runtime-objc-property-setter.test.js b/packages/react-native/test/runtime-objc-property-setter.test.js index 4c4935659..755f12643 100644 --- a/packages/react-native/test/runtime-objc-property-setter.test.js +++ b/packages/react-native/test/runtime-objc-property-setter.test.js @@ -4,36 +4,146 @@ const path = require("path"); const repoRoot = path.resolve(__dirname, "../../.."); -for (const relativePath of [ - "NativeScript/ffi/shared/bridge/HostObjects.mm", - "packages/react-native/native-api/ffi/shared/bridge/HostObjects.mm", +// refactor split the old monolithic HostObjects.mm into host_objects/*.mm, +// included (in this order) by the residual HostObjects.mm. Concatenate them +// back into one logical blob so the substring/ordering assertions below +// (carried over from when this was one file) still hold. +const HOST_OBJECTS_INCLUDE_ORDER = [ + "Interop.mm", + "Struct.mm", + "Appearance.mm", + "Object.mm", + "Class.mm", + "Protocol.mm", +]; + +function readLogicalHostObjects(bridgeDir) { + const residualPath = path.join(bridgeDir, "HostObjects.mm"); + if (!fs.existsSync(residualPath)) { + return null; + } + const parts = [fs.readFileSync(residualPath, "utf8")]; + for (const name of HOST_OBJECTS_INCLUDE_ORDER) { + parts.push(fs.readFileSync(path.join(bridgeDir, "host_objects", name), "utf8")); + } + return parts.join("\n"); +} + +for (const bridgeDir of [ + path.join(repoRoot, "NativeScript/ffi/objc/shared/bridge"), + path.join(repoRoot, "packages/react-native/native-api/ffi/objc/shared/bridge"), ]) { - const source = fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); + const source = readLogicalHostObjects(bridgeDir); + if (source == null) { + // The packages/react-native/native-api mirror is a gitignored build + // artifact produced by `npm run build-rn-turbomodule`; skip it when it + // hasn't been generated (e.g. a fresh checkout). + continue; + } assert( source.includes("runtimeWritablePropertySetter"), - `${relativePath}: host objects should discover writable Objective-C runtime properties`, + `${bridgeDir}: host objects should discover writable Objective-C runtime properties`, ); assert( source.includes("runtimeReadablePropertyGetter"), - `${relativePath}: host objects should discover readable Objective-C runtime properties`, + `${bridgeDir}: host objects should discover readable Objective-C runtime properties`, ); assert( source.includes("property_copyAttributeValue(prop, \"S\")"), - `${relativePath}: runtime property fallback should honor custom Objective-C setters`, + `${bridgeDir}: runtime property fallback should honor custom Objective-C setters`, ); assert( source.includes("property_copyAttributeValue(prop, \"R\")"), - `${relativePath}: runtime property fallback should not assign readonly Objective-C properties`, + `${bridgeDir}: runtime property fallback should not assign readonly Objective-C properties`, ); assert( source.includes("callObjCSelector(runtime, bridge_, object_, false,\n *setterSelectorName, nullptr, args, 1);"), - `${relativePath}: runtime property fallback should invoke the discovered native setter`, + `${bridgeDir}: runtime property fallback should invoke the discovered native setter`, ); assert( source.includes("return callObjectSelector(runtime, *selector, nullptr, nullptr, 0);"), - `${relativePath}: JS-extended instances should read discovered native properties before returning undefined`, + `${bridgeDir}: JS-extended instances should read discovered native properties before returning undefined`, ); } +const runtimeHostObjects = readLogicalHostObjects( + path.join(repoRoot, "NativeScript/ffi/objc/shared/bridge"), +); +const runtimeObjCBridge = fs.readFileSync( + path.join(repoRoot, "NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm"), + "utf8", +); +const appearanceAccessorStart = runtimeHostObjects.indexOf( + "Function makeAppearanceProxyPropertySetter(", +); +const appearanceAccessorEnd = runtimeHostObjects.indexOf( + "\nValue tagStaticAppearanceSelectorResult(", + appearanceAccessorStart, +); +const appearanceAccessorSource = runtimeHostObjects.slice( + appearanceAccessorStart, + appearanceAccessorEnd, +); +const nativeObjectHostObjectStart = runtimeHostObjects.indexOf( + "class NativeApiObjectHostObject final", +); +const appearanceHostSetStart = runtimeHostObjects.indexOf( + "NativeApiHostSetResult set(Runtime& runtime, const PropNameID& name, const Value& value) override", + nativeObjectHostObjectStart, +); +const appearanceHostSetEnd = runtimeHostObjects.indexOf( + "\n if (auto setterSelectorName =", + appearanceHostSetStart, +); +const appearanceHostSetSource = runtimeHostObjects.slice( + appearanceHostSetStart, + appearanceHostSetEnd, +); + +assert( + runtimeHostObjects.includes("Class tagStaticAppearanceNativeResult(") && + runtimeHostObjects.includes("return customizableClass;"), + "runtime UIAppearance result tagging should return the customizable target class", +); +assert( + runtimeHostObjects.includes("appearanceProxyCustomizableClassFromExactDescription(native)") && + runtimeHostObjects.includes("if (customizableClass == Nil) {\n return Nil;\n }") && + !runtimeHostObjects.includes("customizableClass = appearanceClass;"), + "runtime UIAppearance result tagging should require an exact UIKit proxy target and avoid shadowing AppKit appearance objects", +); +assert( + runtimeHostObjects.includes("installAppearanceProxyPropertyAccessors") && + runtimeHostObjects.includes("makeAppearanceProxyPropertySetter") && + runtimeHostObjects.includes("makeAppearanceProxyPropertyGetter") && + runtimeHostObjects.includes("betterAppearanceProxyAccessorMember") && + runtimeHostObjects.includes("std::unordered_map accessors") && + runtimeHostObjects.includes("cacheAppearanceProxyPropertyValue(") && + appearanceAccessorSource.includes("runtimeWritablePropertySetter(native, member.name)") && + appearanceAccessorSource.includes("if (!member.readonly)") && + appearanceAccessorSource.includes("betterAppearanceProxyAccessorMember(accessors[member.name], member)") && + !appearanceAccessorSource.includes("std::unordered_set installed") && + !appearanceAccessorSource.includes("!member.readonly && !member.setterSelectorName.empty()"), + "runtime UIAppearance proxies should install writable-preferred safe property accessors backed by the appearance cache", +); +assert( + appearanceHostSetSource.includes("runtimeWritablePropertySetter(object_, property)") && + appearanceHostSetSource.includes("selectAppearanceProxyPropertyMember(members, property)") && + appearanceHostSetSource.indexOf("taggedAppearanceProxyClass(runtime, bridge_, object_)") < + appearanceHostSetSource.indexOf("findClassForRuntimeClass(object_getClass(object_))") && + !appearanceHostSetSource.includes("propertyMember->readonly ||\n propertyMember->setterSelectorName.empty()"), + "runtime UIAppearance host-object assignment should select proxy members before generic object setters and use the same runtime setter fallback before caching", +); +assert( + !runtimeHostObjects.includes("SetNativeApiObjectPrototype(runtime, resultObject"), + "runtime UIAppearance proxies should not replace their JS prototype with the target class prototype", +); +assert( + runtimeObjCBridge.includes("uintptr_t runtimeObjectExpandoKey(Runtime& runtime)") && + runtimeObjCBridge.includes("runtime.state().get()") && + runtimeObjCBridge.includes("const uintptr_t runtimeKey = runtimeObjectExpandoKey(runtime);") && + !runtimeObjCBridge.includes("const uintptr_t runtimeKey =\n normalizeRuntimePointer(reinterpret_cast(&runtime));"), + "runtime object expandos should use stable backend runtime identity instead of per-callback stack wrapper addresses", +); + console.log("runtime Objective-C property setter tests passed"); diff --git a/packages/react-native/test/runtime-object-conversion-guard.test.js b/packages/react-native/test/runtime-object-conversion-guard.test.js new file mode 100644 index 000000000..67ab4987a --- /dev/null +++ b/packages/react-native/test/runtime-object-conversion-guard.test.js @@ -0,0 +1,68 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const repoRoot = path.resolve(__dirname, "../../.."); +const bridgeSources = [ + { + objcBridge: "NativeScript/ffi/objc/shared/bridge/ObjCBridge.mm", + typeConv: "NativeScript/ffi/objc/shared/bridge/TypeConv.mm", + }, + { + objcBridge: + "packages/react-native/native-api/ffi/objc/shared/bridge/ObjCBridge.mm", + typeConv: + "packages/react-native/native-api/ffi/objc/shared/bridge/TypeConv.mm", + }, +]; + +for (const { objcBridge, typeConv } of bridgeSources) { + const objcBridgePath = path.join(repoRoot, objcBridge); + const typeConvPath = path.join(repoRoot, typeConv); + if (!fs.existsSync(objcBridgePath) || !fs.existsSync(typeConvPath)) { + // packages/react-native/native-api is a gitignored build artifact + // produced by `npm run build-rn-turbomodule`; skip it when it hasn't + // been generated (e.g. a fresh checkout). + continue; + } + const objcBridgeSource = fs.readFileSync(objcBridgePath, "utf8"); + const typeConvSource = fs.readFileSync(typeConvPath, "utf8"); + + assert( + objcBridgeSource.includes("bool nativeObjectPointerMayBeObject(id object)"), + `${objcBridge}: should expose a shared object-pointer validity guard`, + ); + assert( + objcBridgeSource.includes("return raw > 0x1000;"), + `${objcBridge}: should reject impossible immediate pointers before ObjC messaging`, + ); + + const stringLikeSource = objcBridgeSource.slice( + objcBridgeSource.indexOf("bool nativeObjectIsStringLike"), + objcBridgeSource.indexOf("Value findCachedNativeObjectReturn"), + ); + assert( + stringLikeSource.indexOf("!nativeObjectPointerMayBeObject(object)") >= 0 && + stringLikeSource.indexOf("!nativeObjectPointerMayBeObject(object)") < + stringLikeSource.indexOf("object_getClass(object)"), + `${objcBridge}: string-like checks must guard before object_getClass`, + ); + + const objectReturnSource = typeConvSource.slice( + typeConvSource.indexOf("case metagen::mdTypeAnyObject"), + typeConvSource.indexOf("case metagen::mdTypeFunctionReference"), + ); + assert( + objectReturnSource.indexOf("!nativeObjectPointerMayBeObject(object)") >= 0 && + objectReturnSource.indexOf("!nativeObjectPointerMayBeObject(object)") < + objectReturnSource.indexOf("findCachedNativeObjectReturn"), + `${typeConv}: object conversion must reject invalid pointers before cache lookup`, + ); + assert( + objectReturnSource.indexOf("!nativeObjectPointerMayBeObject(object)") < + objectReturnSource.indexOf("[object isKindOfClass:[NSNull class]]"), + `${typeConv}: object conversion must reject invalid pointers before isKindOfClass`, + ); +} + +console.log("runtime object conversion guard tests passed"); diff --git a/packages/react-native/test/uikit-controller-host-view-api.test.js b/packages/react-native/test/uikit-controller-host-view-api.test.js index 7951dc631..70ed23d6c 100644 --- a/packages/react-native/test/uikit-controller-host-view-api.test.js +++ b/packages/react-native/test/uikit-controller-host-view-api.test.js @@ -18,20 +18,348 @@ assert( "defineUIViewController should use the resolved host view before falling back to controller.view", ); -const declarations = read("src/index.d.ts"); +const declarations = read("src/index.ts"); assert( declarations.includes("hostView?: (controller: Controller) => unknown"), "public declarations should expose UIViewControllerDefinition.hostView", ); +assert( + index.includes("detachControllerFromParent?: boolean") && + index.includes("attachControllerToParent?: boolean") && + index.includes("pinNativeViewToHost?: boolean") && + declarations.includes("detachControllerFromParent?: boolean") && + declarations.includes("attachControllerToParent?: boolean") && + declarations.includes("pinNativeViewToHost?: boolean") && + read("src/NativeScriptUIViewNativeComponent.ts").includes( + "detachControllerFromParent?: boolean", + ) && + read("src/NativeScriptUIViewNativeComponent.ts").includes( + "attachControllerToParent?: boolean", + ) && + read("src/NativeScriptUIViewNativeComponent.ts").includes( + "pinNativeViewToHost?: boolean", + ), + "defineUIKitHost should expose generic controller-parent and hosted-view layout controls", +); +assert( + index.includes("invokeObjCSelector,") && + index.includes("function tryNativeHandleForNSObject") && + index.includes("return false;") && + index.includes("const handle = tryNativeHandleForNSObject(arg);") && + index.includes("function encodeObjCSelectorArgument") && + index.includes("Array.isArray(arg)") && + index.includes('const object = nativeObjectFromHandle(result);') && + index.includes("return object ?? (result as ReturnValue);") && + index.includes("export function invokeObjCSelector") && + index.includes(".__nativeScriptInvokeObjCSelector") && + declarations.includes("export type ObjCSelectorArgument") && + declarations.includes("invokeObjCSelector") && + !index.includes("attachViewControllerToNearestParent") && + !index.includes("nearestViewController"), + "NativeScript default export should include generic ObjC selector invocation (nearest-parent attachment + nearestViewController removed as unused surface)", +); + +const nativeApiModule = read("ios/NativeScriptNativeApiModule.mm"); +assert( + nativeApiModule.includes("__nativeScriptInvokeObjCSelector") && + nativeApiModule.includes("nativeScriptInvokeObjCSelectorFromHandles") && + nativeApiModule.includes("[target respondsToSelector:selector]") && + nativeApiModule.includes("NSInvocation* invocation") && + nativeApiModule.includes("nativeScriptSetInvocationArgument") && + nativeApiModule.includes("object.isArray(runtime)") && + nativeApiModule.includes("NSMutableArray* result") && + nativeApiModule.includes("nativeScriptJSIValueFromInvocationReturn"), + "NativeScript worklet runtime should expose a synchronous ObjC selector primitive for UIKit wrappers", +); + +assert( + nativeApiModule.includes("case 'Q':") && + nativeApiModule.includes("number.unsignedLongLongValue") && + nativeApiModule.includes("if (code == 'Q')") && + nativeApiModule.includes("static_cast(longLongValue)") && + nativeApiModule.includes("case 'L':") && + nativeApiModule.includes("if (code == 'L')"), + "NativeScript worklet selector invocation should preserve unsigned ObjC integer arguments and return values", +); const nativeHost = read("ios/NativeScriptUIView.mm"); assert( - nativeHost.includes("if (_nativeViewHandle.length == 0) {\n [self setNativeView:_viewController.view];"), - "NativeScriptUIView should not overwrite an explicit native host view with controller.view", + !nativeHost.includes("hostMountRetry") && + !nativeHost.includes("retryHostId"), + "NativeScriptUIView should not retry host mounting; React registers UI host factories synchronously before Fabric commits", +); +assert( + index.includes("attachNativeView?: boolean") && + declarations.includes("attachNativeView?: boolean") && + read("src/NativeScriptUIViewNativeComponent.ts").includes( + "attachNativeView?: boolean", + ) && + read("ios/NativeScriptUIView.h").includes( + "@property(nonatomic, assign) BOOL attachNativeView", + ) && + nativeHost.includes("_attachNativeView = NO;") && + nativeHost.includes("- (void)setAttachNativeView:(BOOL)attachNativeView") && + nativeHost.includes("[self clearNativeViewAttachmentIfOwnedByHost];") && + nativeHost.includes("if (_attachNativeView && _nativeViewHandle.length == 0)") && + index.includes("attachNativeView,") && + read("ios/NativeScriptUIViewManager.mm").includes( + "RCT_EXPORT_VIEW_PROPERTY(attachNativeView, BOOL)", + ) && + read("ios/Fabric/NativeScriptUIViewComponentView.mm").includes( + "oldViewProps->attachNativeView", + ) && + read("ios/Fabric/NativeScriptUIViewComponentView.mm").includes( + "_containerView.attachNativeView = newAttachNativeView", + ) && + read("ios/Fabric/NativeScriptUIViewComponentView.mm").includes( + "_containerView.attachNativeView = NO", + ), + "NativeScriptUIView should make attachNativeView a real Fabric/Paper prop so externally owned controller views are not hosted by the wrapper", +); +assert( + nativeHost.includes("if (_attachNativeView && _nativeViewHandle.length == 0) {\n [self setNativeView:_viewController.view];"), + "NativeScriptUIView should not overwrite an explicit native host view or an attachNativeView=false host with controller.view", ); assert( nativeHost.includes("[self attachViewControllerIfPossible];"), "NativeScriptUIView should still attach the controller for lifecycle when a custom host view is used", ); +assert( + nativeHost.includes("const BOOL nativeViewIsDetachedControllerView =") && + nativeHost.includes("const BOOL nextNativeViewIsDetachedControllerView =") && + nativeHost.includes( + "_detachControllerView && _viewController != nil && nativeView == _viewController.view", + ) && + !nativeHost.includes( + "if (_detachControllerView && _viewController != nil && nativeView == _viewController.view) {\n nativeView = nil;", + ) && + nativeHost.includes("const BOOL nextNativeViewIsExternallyWindowOwned =") && + nativeHost.includes( + "nextNativeViewIsDetachedControllerView && nativeView.superview != nil", + ) && + nativeHost.includes("nativeView.superview != self && nativeView.window != nil") && + nativeHost.includes( + "if (nextNativeViewIsExternallyWindowOwned) {\n [self moveReactSubviewsToChildrenView];", + ) && + nativeHost.includes( + "[self moveReactSubviewsToChildrenView];\n [self refreshDetachedChildrenHost];", + ) && + nativeHost.includes( + "[self refreshDetachedChildrenHost];\n [_nativeView setNeedsDisplay];", + ) && + nativeHost.includes( + "_detachControllerView && nextController != nil && nextNativeView == nextController.view", + ) && + nativeHost.includes("const BOOL mustClearDetachedControllerView =") && + nativeHost.includes( + "_detachControllerView && _viewController != nil && _nativeView == _viewController.view", + ), + "NativeScriptUIView should detect controller-owned detached views before moving them through the host wrapper", +); +const applyHandlesStart = nativeHost.indexOf( + "- (void)applyUIKitHostHandles:(NSDictionary*)handles", +); +const applyHandlesSource = nativeHost.slice( + applyHandlesStart, + nativeHost.indexOf("- (void)mountUIKitHostIfNeeded", applyHandlesStart), +); +const detachedBranchStart = applyHandlesSource.indexOf( + "if (nativeViewIsDetachedControllerView)", +); +const detachedBranchSource = applyHandlesSource.slice( + detachedBranchStart, + applyHandlesSource.indexOf("} else {", detachedBranchStart), +); +assert( + detachedBranchSource.indexOf("self.controllerHandle = controllerHandle;") >= 0 && + detachedBranchSource.indexOf("self.childrenViewHandle = childrenViewHandle;") > + detachedBranchSource.indexOf("self.controllerHandle = controllerHandle;") && + detachedBranchSource.indexOf("self.nativeViewHandle = nativeViewHandle;") > + detachedBranchSource.indexOf("self.childrenViewHandle = childrenViewHandle;"), + "NativeScriptUIView should apply detached controller-view hosts as controller, children, then native handle so the controller view is never transiently hosted", +); +assert( + nativeHost.includes("_attachControllerToParent = NO;") && + nativeHost.includes( + "- (void)setAttachControllerToParent:(BOOL)attachControllerToParent", + ) && + nativeHost.includes("[self detachViewControllerIfOwnedByHost];") && + nativeHost.includes("if (!_attachControllerToParent || _detachControllerFromParent"), + "NativeScriptUIView should wait for explicit controller-parent attachment props before owning parent containment", +); +assert( + nativeHost.includes("- (void)setPinNativeViewToHost:(BOOL)pinNativeViewToHost") && + nativeHost.includes("const BOOL nativeViewIsOwnedByHost = _nativeView.superview == self;") && + nativeHost.includes("if (!nativeViewIsOwnedByHost) {\n [self deactivateNativeViewHostConstraints];\n return;\n }") && + nativeHost.includes("[_nativeView.topAnchor constraintEqualToAnchor:self.topAnchor]") && + nativeHost.includes("[_nativeView.bottomAnchor constraintEqualToAnchor:self.bottomAnchor]") && + nativeHost.includes("BOOL hasInactiveConstraint = NO;") && + nativeHost.includes("if (!constraint.active)") && + nativeHost.includes("if (hasInactiveConstraint) {\n [NSLayoutConstraint activateConstraints:_nativeViewHostConstraints];\n }") && + nativeHost.includes("[NSLayoutConstraint activateConstraints:_nativeViewHostConstraints]") && + nativeHost.includes("const BOOL ownsNativeViewAsSubview = _nativeView != nil && _nativeView.superview == self;") && + nativeHost.includes("const BOOL didResizeNativeView =\n ownsNativeViewAsSubview && !_pinNativeViewToHost &&") && + nativeHost.includes("!CGRectEqualToRect(_nativeView.frame, self.bounds);"), + "NativeScriptUIView should optionally pin only host-owned UIKit views with constraints instead of frame/autoresizing layout", +); +assert( + nativeHost.includes("hostedViewToReinsert = [_nativeView retain];") && + nativeHost.includes("hostedViewIndex = [self.subviews indexOfObject:hostedViewToReinsert];") && + nativeHost.includes("[self deactivateNativeViewHostConstraints];\n [hostedViewToReinsert removeFromSuperview];"), + "NativeScriptUIView should rebuild pinned constraints after temporarily removing a hosted controller view for UIKit containment", +); +assert( + nativeHost.includes("- (void)layoutHostedViewControllerViewIfNeeded") && + nativeHost.includes("[_nativeView setNeedsLayout];\n [_nativeView layoutIfNeeded];") && + nativeHost.includes("[self layoutHostedViewControllerViewIfNeeded];\n [_viewController didMoveToParentViewController:parent];") && + nativeHost.includes("[_viewController didMoveToParentViewController:parent];\n [self layoutHostedViewControllerViewIfNeeded];") && + nativeHost.includes( + "- (void)setPinNativeViewToHost:(BOOL)pinNativeViewToHost" + ) && + nativeHost.includes( + "_pinNativeViewToHost = pinNativeViewToHost;\n [self applyNativeViewLayoutMode];\n [self layoutHostedViewControllerViewIfNeeded];" + ) && + nativeHost.includes("if (_pinNativeViewToHost || didResizeNativeView) {\n [self layoutHostedViewControllerViewIfNeeded];") && + nativeHost.includes("[self layoutHostedViewControllerViewIfNeeded];\n [self setNeedsLayout];"), + "NativeScriptUIView should synchronously lay out controller-owned hosted views after containment, pinning, and size changes", +); +const setViewControllerStart = nativeHost.indexOf( + "- (void)setViewController:(UIViewController*)viewController", +); +const setViewControllerSource = nativeHost.slice( + setViewControllerStart, + nativeHost.indexOf( + "- (void)attachViewControllerIfPossible", + setViewControllerStart, + ), +); +assert( + setViewControllerSource.includes("[self setNeedsLayout];") && + !setViewControllerSource.includes("[self attachViewControllerIfPossible];") && + setViewControllerSource.includes("if (_detachControllerFromParent) {\n [self detachViewController];"), + "NativeScriptUIView should defer first controller attachment until host detach props are applied and detach pre-parented controllers when requested", +); +assert( + nativeHost.includes("view.window.rootViewController") && + nativeHost.includes("NativeScriptTopMostViewControllerForWindow") && + nativeHost.includes("controller.presentedViewController"), + "NativeScriptUIView should fall back to the window root/top-presented controller when the responder chain has no parent controller", +); +assert( + nativeHost.includes("NativeScriptControllerHierarchyContainsController") && + nativeHost.includes("UINavigationController.class") && + nativeHost.includes("UITabBarController.class") && + nativeHost.includes("UISplitViewController.class"), + "NativeScriptUIView should be able to prove UIKit containment through common controller containers", +); +assert( + nativeHost.includes( + "NativeScriptNearestViewController(UIView* view, UIViewController* excludedController)", + ) && + nativeHost.includes("#import ") && + nativeHost.includes("NativeScriptReactViewControllerForView") && + nativeHost.includes("view.reactViewController") && + nativeHost.includes("NativeScriptReactSuperviewForView") && + nativeHost.includes("view.reactSuperview ?: view.superview") && + nativeHost.includes("NativeScriptClosestReactViewControllerForView") && + nativeHost.includes("NativeScriptNearestViewControllerForView") && + nativeHost.includes( + "UIViewController* controller = NativeScriptClosestReactViewControllerForView(view, nil);", + ) && + nativeHost.includes( + "controller = NativeScriptNearestViewController(view, nil);", + ) && + nativeHost.includes( + "UIViewController* parent = NativeScriptClosestReactViewControllerForView(view, controller);", + ) && + nativeHost.includes( + "parent = NativeScriptNearestResponderViewController(view, controller);", + ) && + nativeHost.includes("responder != excludedController") && + nativeHost.includes( + "NativeScriptNearestViewController(self, _viewController)", + ), + "NativeScriptUIView should mirror RN reactViewController/reactSuperview lookup before responder fallback and skip the hosted controller itself when searching for a UIKit parent", +); +assert( + nativeHost.includes("NativeScriptHostedViewOwnerKey") && + nativeHost.includes("NativeScriptSetHostedViewOwner(_nativeView, self)") && + nativeHost.includes("NativeScriptRefreshUIKitHostOwnersInAncestorChain(view)") && + nativeHost.includes("[owner attachViewControllerIfPossible]"), + "refreshUIKitHostView should refresh the owning host and retry controller containment from hosted UIKit descendants", +); +assert( + nativeHost.includes("_detachControllerFromParent || _detachControllerView") && + nativeHost.includes("_viewController.presentingViewController != nil") && + nativeHost.includes("_viewController.isBeingPresented") && + nativeHost.includes("_viewController.isBeingDismissed") && + !nativeHost.includes("_viewController.presentationController != nil"), + "NativeScriptUIView should skip parent attachment for externally owned or actively presented controllers without treating a precreated presentationController as presented", +); +assert( + !nativeHost.includes("_viewController.parentViewController != nil ||") && + nativeHost.includes("_viewController.parentViewController == parent") && + nativeHost.includes("NativeScriptControllerHierarchyContainsController(rootController, _viewController)") && + nativeHost.includes( + "rootController == nil ||\n NativeScriptControllerHierarchyContainsController(rootController, _viewController)", + ) && + nativeHost.includes("[self detachViewControllerIfOwnedByHost];\n if (_viewController.parentViewController != nil)") && + nativeHost.includes("[parent addChildViewController:_viewController]"), + "NativeScriptUIView should reparent hosted controllers when the current parent is detached from the window root", +); +assert( + nativeHost.includes("UIViewController* _attachedViewControllerParent;") && + nativeHost.includes("_attachedViewControllerParent = parent;") && + nativeHost.includes("- (void)detachViewControllerIfOwnedByHost") && + nativeHost.includes("_viewController.parentViewController != _attachedViewControllerParent") && + nativeHost.includes("if (_attachedViewControllerParent == nil ||\n _viewController.parentViewController != _attachedViewControllerParent) {\n return;\n }"), + "NativeScriptUIView should track and detach only controller parents it attached itself", +); +const detachControllerFromParentStart = nativeHost.indexOf( + "- (void)setDetachControllerFromParent:(BOOL)detachControllerFromParent", +); +const detachControllerFromParentSource = nativeHost.slice( + detachControllerFromParentStart, + nativeHost.indexOf("- (void)setDebugName:", detachControllerFromParentStart), +); +assert( + detachControllerFromParentSource.includes( + "[self detachViewController];", + ) && detachControllerFromParentSource.includes("_attachedViewControllerParent = nil;"), + "detachControllerFromParent should generically detach the controller from any current parent", +); +assert( + read("ios/NativeScriptUIViewManager.mm").includes( + "RCT_EXPORT_VIEW_PROPERTY(detachControllerFromParent, BOOL)", + ) && + read("ios/NativeScriptUIViewManager.mm").includes( + "RCT_EXPORT_VIEW_PROPERTY(attachControllerToParent, BOOL)", + ) && + read("ios/NativeScriptUIViewManager.mm").includes( + "RCT_EXPORT_VIEW_PROPERTY(attachNativeView, BOOL)", + ) && + read("ios/NativeScriptUIViewManager.mm").includes( + "RCT_EXPORT_VIEW_PROPERTY(pinNativeViewToHost, BOOL)", + ) && + read("ios/Fabric/NativeScriptUIViewComponentView.mm").includes( + "_containerView.detachControllerFromParent = newDetachControllerFromParent", + ) && + read("ios/Fabric/NativeScriptUIViewComponentView.mm").includes( + "_containerView.attachControllerToParent = newAttachControllerToParent", + ) && + read("ios/Fabric/NativeScriptUIViewComponentView.mm").includes( + "_containerView.attachNativeView = newAttachNativeView", + ) && + read("ios/Fabric/NativeScriptUIViewComponentView.mm").includes( + "_containerView.attachControllerToParent = NO", + ) && + read("ios/Fabric/NativeScriptUIViewComponentView.mm").includes( + "_containerView.pinNativeViewToHost = newPinNativeViewToHost", + ) && + read("ios/Fabric/NativeScriptUIViewComponentView.mm").includes( + "_containerView.pinNativeViewToHost = NO", + ), + "NativeScriptUIView should wire controller-parent ownership and hosted-view layout controls through Paper and Fabric", +); console.log("uikit controller host-view API tests passed"); diff --git a/packages/react-native/test/uikit-gesture-action-api.test.js b/packages/react-native/test/uikit-gesture-action-api.test.js index 1d40d977b..8e736c80c 100644 --- a/packages/react-native/test/uikit-gesture-action-api.test.js +++ b/packages/react-native/test/uikit-gesture-action-api.test.js @@ -13,6 +13,17 @@ assert( index.includes("gestureAction("), "UIKit context should expose a gestureAction helper", ); +assert( + index.includes("delegate(object, protocolRef, implementation, options = {})"), + "UIKit context delegate helper should accept delegate creation options", +); +assert( + index.includes("wrapDelegateMethods(implementation, options.thread ?? \"caller\")") && + index.includes("const owner = options.owner ?? context") && + index.includes("const assignedObject = (options.assignTo?.object ??") && + index.includes("assignedObject[assignedProperty] = delegate"), + "UIKit context delegate helper should support thread, owner, and assignTo options inline on the UI runtime", +); assert( index.includes("targetAction(control, events, callback)"), "UIKit context should expose a targetAction helper", @@ -21,10 +32,82 @@ assert( index.includes("actionTarget(callback)"), "UIKit context should expose a generic target/action helper", ); +assert( + index.includes("function createNativeActionTarget(") && + !index.includes("export function createNativeActionTarget("), + "runtime should keep a standalone native target/action helper as an internal primitive", +); +assert( + index.includes("function invokeNativeActionTarget(") && + !index.includes("export function invokeNativeActionTarget("), + "runtime should keep a generic worklet-block target/action invoker as an internal primitive", +); +assert( + index.includes("function createNativeUIAction(") && + !index.includes("export function createNativeUIAction("), + "runtime should keep a retained UIAction helper as an internal primitive", +); +assert( + index.includes("function canCreateNativeUIAction()") && + !index.includes("export function canCreateNativeUIAction()"), + "runtime should keep UIAction helper availability as an internal primitive", +); +assert( + index.includes("function canCreateNativeActionTarget()") && + !index.includes("export function canCreateNativeActionTarget()"), + "runtime should keep target/action availability as an internal primitive", +); +assert( + index.includes("if (!canCreateNativeActionTarget())"), + "standalone native target/action helper should guard runtimes without class extension support", +); +assert( + index.includes('const nsObject = nativeApiClass("NSObject")') && + index.includes('getClass("NSObject")') && + index.includes("const extendClass = api.__extendClass") && + !index.includes("(globalThis as Record).NSObject"), + "UIKit target/action availability should use UI-safe native class lookup while allocation still uses lazy Native API class wrappers", +); +assert( + index.includes("Object.prototype.hasOwnProperty.call(target, property)") && + index.includes("if (nativeValue !== undefined)") && + !index.includes("if (property in target) {\n return Reflect.get(target, property, receiver);\n }\n if (cachedNativeFunctions.has(property))"), + "extended Native API class wrappers should resolve subclass native methods before inherited base wrapper methods", +); +assert( + index.includes('Object.defineProperty(constructable, "construct"') && + index.includes('Object.defineProperty(constructable, "alloc"') && + index.includes("return rememberInstanceClass(cls.alloc());") && + index.includes("typeof cls.new === \"function\"") && + index.includes("return rememberInstanceClass(cls.new());"), + "Native API class wrappers should expose own construct/alloc/new methods so extended classes allocate and initialize their own native class", +); +assert( + index.includes("function rememberNativeObjectClass") && + index.includes("__rememberObjectClassWrapper") && + index.includes("rememberNativeObjectClass(value, wrapper || constructable)"), + "Native API class wrappers should remember object instances against their JS wrapper", +); +assert( + index.includes("function createNativeClassInstance") && + index.includes("nativeClass.new()") && + index.includes("nativeClass.alloc()") && + !index.includes("getTargetActionClass().alloc().init()") && + !index.includes("DelegateClass.alloc().init()") && + !index.includes("getObserverClass().alloc().init()"), + "runtime-generated target/delegate/observer classes should instantiate through the generic native class helper", +); assert( index.includes("function invokeNativeScriptCallback("), "UIKit native callbacks should route through a shared callback scheduler", ); +assert( + index.includes("const delegateClassOptions: Record = {\n protocols: protocolList,") && + index.includes("if (options.name) {\n delegateClassOptions.name = options.name;\n }") && + index.includes("delegateClassOptions,\n );") && + !index.includes("name: options.name,"), + "createDelegate should omit undefined class names when extending NSObject", +); assert( index.includes('nativeScriptCallbackThread(callback) !== "js"'), "callback scheduler should distinguish JS-owned callbacks from runtime callbacks", @@ -53,23 +136,71 @@ assert( index.includes("invokeNativeScriptCallback(callback, [sender], () => disposed)"), "actionTarget should honor callback thread policy and pass the sender", ); +assert( + index.includes("targetActionCallbacksForRuntime().set(targetKey, (sender) =>") && + index.includes("targetActionCallbacksForRuntime().delete(targetKey)") && + index.includes("callbackKey: targetKey") && + index.includes("invokeNativeScriptCallback(callback, [sender], () => disposed)") && + index.includes("invoke,"), + "standalone native action targets should retain callbacks, expose their stable callback key, provide direct worklet invocation, and dispose them", +); +assert( + index.includes('const block = InteropBlock(\n "v@?@",') && + index.includes("eventBridge((sender: unknown) =>") && + index.includes('}, "runtime")') && + index.includes('const defaultNativeRetainerGlobalName = "__nativeScriptDefaultNativeRetainer";') && + index.includes("function defaultNativeRetainerForRuntime(): NativeRetainer") && + index.includes("const retainer = defaultNativeRetainerForRuntime();") && + index.includes("retainer.retain(actionTarget.target)") && + index.includes("retainer.retain(block)") && + index.includes("retainer.retain(action)") && + index.includes('"__nativeScriptUIActionTarget"') && + index.includes('"__nativeScriptUIActionBlock"') && + index.includes("actionTarget.dispose();"), + "native UIActions should retain their block/action target lifetimes per runtime and dispose callback table entries", +); +assert( + index.includes('const invokeNativeActionTargetGlobalName =\n "__nativeScriptInvokeNativeActionTarget";') && + index.includes('if (typeof actionTarget?.invoke === "function")') && + index.includes("return actionTarget.invoke(sender) === true;") && + index.includes("function invokeNativeActionTargetFromRuntime(") && + index.includes("function installNativeActionTargetInvoker()") && + index.includes("installNativeActionTargetInvoker();") && + index.includes('typeof actionTarget.callbackKey === "string"') && + index.includes("const callback = targetActionCallbacksForRuntime().get(targetKey);") && + index.includes("callback(sender);\n return true;"), + "invokeNativeActionTarget should use a UI-runtime global entrypoint backed by the target/action callback table", +); +assert( + index.includes("observeValueForKeyPathOfObjectChangeContext(") && + index.includes('"observeValueForKeyPath:ofObject:change:context:"') && + index.includes("observerCallbacksForRuntime().get("), + "KVO observers should implement the JSified NativeScript selector while exposing the Objective-C selector", +); assert( index.includes('action: "nativeScriptHandleAction:"'), "actionTarget should return the Objective-C selector name", ); -const declarations = read("src/index.d.ts"); -assert( - declarations.includes("gestureAction("), - "public declarations should expose gestureAction", -); +const declarations = read("src/index.ts"); assert( - declarations.includes("callback: (gesture: unknown) => void"), - "gestureAction declarations should pass the recognizer to callbacks", + declarations.includes("gestureAction(") && + declarations.includes("callback: (gesture: unknown) => void") && + declarations.includes("actionTarget(callback: (sender: unknown) => void)"), + "UIKitViewContext should expose gestureAction/actionTarget helpers that pass the recognizer/sender to callbacks", ); assert( - declarations.includes("actionTarget(callback: (sender: unknown) => void)"), - "public declarations should expose generic actionTarget", + declarations.includes("export type NativeActionTarget") && + declarations.includes("callbackKey: string;") && + declarations.includes("invoke(sender?: unknown): boolean;") && + declarations.includes("export type NativeUIAction") && + declarations.includes("function canCreateNativeActionTarget(") && + declarations.includes("function createNativeActionTarget(") && + declarations.includes("function invokeNativeActionTarget(") && + declarations.includes("function canCreateNativeUIAction(") && + declarations.includes("function createNativeUIAction(") && + !declarations.includes("createNativeUIAction: typeof createNativeUIAction"), + "NativeActionTarget/NativeUIAction stay public types while their action-target factories became internal primitives", ); console.log("uikit gesture action API tests passed"); diff --git a/packages/react-native/test/uikit-host-detached-wrapper-api.test.js b/packages/react-native/test/uikit-host-detached-wrapper-api.test.js new file mode 100644 index 000000000..e5d05a93c --- /dev/null +++ b/packages/react-native/test/uikit-host-detached-wrapper-api.test.js @@ -0,0 +1,167 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const packageRoot = path.resolve(__dirname, ".."); + +function read(relativePath) { + return fs.readFileSync(path.join(packageRoot, relativePath), "utf8"); +} + +const hostHeader = read("ios/NativeScriptUIView.h"); +const hostView = read("ios/NativeScriptUIView.mm"); +const fabricView = read("ios/Fabric/NativeScriptUIViewComponentView.mm"); +const index = read("src/index.ts"); +const declarations = read("src/index.ts"); +const nativeComponent = read("src/NativeScriptUIViewNativeComponent.ts"); +const manager = read("ios/NativeScriptUIViewManager.mm"); + +assert( + hostHeader.includes("- (BOOL)shouldHideEmptyFabricHostWrapper"), + "NativeScriptUIView should expose whether its empty Fabric wrapper must be touch-transparent", +); +assert( + hostView.includes("- (BOOL)hostedViewIsDetachedFromHostWrapper:(UIView*)hostedView") && + hostView.includes("!NativeScriptViewIsDescendantOfView(hostedView, self)") && + hostView.includes("hostedView.window == nil") && + hostView.includes("hostedView.hidden || hostedView.alpha <= 0.01"), + "NativeScriptUIView should recognize real hosted UIKit content that moved outside the Fabric wrapper", +); +assert( + hostView.includes("static UIView* NativeScriptHitTestVisibleDescendantOutsideBounds") && + hostView.includes("depth > 16") && + hostView.includes("[subviews reverseObjectEnumerator]") && + hostView.includes("[subview hitTest:subviewPoint withEvent:event]") && + hostView.includes( + "NativeScriptHitTestVisibleDescendantOutsideBounds(subview, subviewPoint, event, depth + 1)", + ) && + hostView.includes( + "NativeScriptHitTestVisibleDescendantOutsideBounds(hostedView, hostedPoint, event, 0)", + ), + "NativeScriptUIView should hit-test visible hosted descendants even when an internal carrier view has zero bounds", +); +assert( + hostView.includes("const BOOL subviewIsHostPlumbing = NativeScriptViewIsHostHitTestPlumbing(subview);") && + hostView.includes("if ((!subviewIsHostPlumbing && [subview pointInside:subviewPoint withEvent:event])") && + hostView.includes("if (!subviewIsHostPlumbing) {\n hitView = [subview hitTest:subviewPoint withEvent:event];") && + hostView.includes( + "NativeScriptHostedOwnerViewHitTestExcludingHost(subview, hostView, subviewPoint, event, depth + 1)", + ), + "NativeScriptUIView should traverse NativeScript host plumbing directly instead of re-entering host hitTest while searching detached descendants", +); +assert( + hostView.includes("- (BOOL)hasVisibleSubviewMountedInHostWrapper") && + hostView.includes("subview == _detachedTouchSentinel") && + hostView.includes("subview.hidden || subview.alpha <= 0.01"), + "NativeScriptUIView should only hide wrappers that have no visible mounted content of their own", +); +assert( + hostView.includes("- (BOOL)shouldHideEmptyFabricHostWrapper") && + hostView.includes("[self hostedViewIsDetachedFromHostWrapper:_nativeView]") && + hostView.includes("_childrenView != _nativeView") && + hostView.includes( + "if ([self hasVisibleSubviewMountedInHostWrapper]) {\n return NO;\n }", + ) && + hostView.includes( + "_externalDetachedChildrenOwner && (_nativeView != nil || _childrenView != nil)", + ) && + hostView.includes( + "return hasDetachedHostedContent || hasExternalDetachedChildrenOwner;", + ), + "NativeScriptUIView should make empty detached or externally-owned Fabric wrappers touch-transparent without hiding locally mounted visible children", +); +assert( + hostView.includes( + "if ([super pointInside:point withEvent:event] && ![self shouldHideEmptyFabricHostWrapper])", + ) && + hostView.includes( + "if (hitView == self &&\n ([self shouldHideEmptyFabricHostWrapper] || NativeScriptViewIsHostHitTestPlumbing(self)))", + ), + "NativeScriptUIView should keep empty detached wrappers transparent to generic UIView hit testing", +); +assert( + fabricView.includes("static BOOL NativeScriptFabricViewIsHostHitTestPlumbing(UIView* view)") && + fabricView.includes('[className isEqualToString:@"NativeScriptUIViewComponentView"]'), + "NativeScriptUIViewComponentView should classify inert host wrappers as touch-transparent plumbing", +); +assert( + fabricView.includes("- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent*)event") && + fabricView.includes( + "superResult && ![_containerView shouldHideEmptyFabricHostWrapper]", + ) && + fabricView.includes( + "if (hitView == self &&\n ([_containerView shouldHideEmptyFabricHostWrapper] ||\n NativeScriptFabricViewIsHostHitTestPlumbing(self)))", + ) && + !fabricView.includes("if (self.hidden) {\n return NO;\n }") && + !fabricView.includes("if (self.hidden) {\n return nil;\n }"), + "NativeScriptUIViewComponentView should stay visible for UIKit traversal while keeping empty wrappers touch-transparent", +); +assert( + fabricView.includes("self.hidden = NO;") && + fabricView.includes( + "const BOOL externallyOwned = _containerView.externalDetachedChildrenOwner;", + ) && + fabricView.includes("self.accessibilityElementsHidden = externallyOwned;") && + fabricView.includes( + "_containerView.accessibilityElementsHidden = externallyOwned;", + ) && + hostView.includes("NativeScriptViewHasHiddenUIKitAncestor(self)"), + "NativeScriptUIViewComponentView should hide externally owned Fabric wrappers from UIKit accessibility without exposing hidden staging owners", +); +assert( + hostView.includes("- (NSArray*)accessibilityElements") && + hostView.includes("return [super accessibilityElements];") && + !hostView.includes("[elements addObject:hostedView]"), + "NativeScriptUIView should not re-export detached hosted UIKit views through the Fabric shell accessibility tree", +); +assert( + index.includes("externalDetachedChildrenOwner?: boolean") && + index.includes('"externalDetachedChildrenOwner"') && + index.includes("props.externalDetachedChildrenOwner === true") && + declarations.includes("externalDetachedChildrenOwner?: boolean") && + nativeComponent.includes("externalDetachedChildrenOwner?: boolean") && + hostHeader.includes( + "@property(nonatomic, assign) BOOL externalDetachedChildrenOwner", + ) && + manager.includes( + "RCT_EXPORT_VIEW_PROPERTY(externalDetachedChildrenOwner, BOOL)", + ) && + fabricView.includes("oldViewProps->externalDetachedChildrenOwner") && + fabricView.includes( + "_containerView.externalDetachedChildrenOwner = newExternalDetachedChildrenOwner;", + ) && + fabricView.includes("_containerView.externalDetachedChildrenOwner = NO;"), + "NativeScriptUIView should expose a generic mode for detached children owned by an external UIKit container", +); +assert( + hostView.includes("if (_externalDetachedChildrenOwner) {\n return NO;\n }") && + hostView.includes("if (_externalDetachedChildrenOwner) {\n return hitView;\n }") && + hostView.includes("return [super accessibilityElements];"), + "NativeScriptUIView should not route hit-testing or shell accessibility through externally owned detached children", +); +assert( + fabricView.includes( + "if (_containerView.externalDetachedChildrenOwner) {\n return NO;\n }", + ) && + fabricView.includes( + "if (_containerView.externalDetachedChildrenOwner) {\n return nil;\n }", + ), + "NativeScriptUIViewComponentView should make externally owned Fabric wrappers touch-inert so UIKit's real owner receives the event", +); +assert( + index.includes(" nativeViewHandle,\n") && + !index.includes("nativeViewHandle: attachNativeView"), + "defineUIKitHost should pass the host view handle even when attachNativeView=false so Fabric lifecycle events identify externally owned UIKit roots without attaching them", +); +assert( + hostView.includes( + "if (!_attachNativeView) {\n [self clearNativeViewAttachmentIfOwnedByHost];\n [self notifyHostReadyIfNeeded];\n return;\n }", + ) && + hostView.includes( + "else if (!_attachNativeView && nativeViewHandle.length > 0)", + ) && + hostView.includes("_nativeViewHandle = [nativeViewHandle copy];"), + "NativeScriptUIView should store nativeViewHandle as identity-only when attachNativeView=false", +); + +console.log("uikit detached wrapper tests passed"); diff --git a/packages/react-native/test/uikit-host-direct-children-mount-api.test.js b/packages/react-native/test/uikit-host-direct-children-mount-api.test.js new file mode 100644 index 000000000..41a744184 --- /dev/null +++ b/packages/react-native/test/uikit-host-direct-children-mount-api.test.js @@ -0,0 +1,130 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const packageRoot = path.resolve(__dirname, ".."); + +function read(relativePath) { + return fs.readFileSync(path.join(packageRoot, relativePath), "utf8"); +} + +const index = read("src/index.ts"); +const declarations = read("src/index.ts"); +const nativeComponent = read("src/NativeScriptUIViewNativeComponent.ts"); +const hostHeader = read("ios/NativeScriptUIView.h"); +const hostView = read("ios/NativeScriptUIView.mm"); +const manager = read("ios/NativeScriptUIViewManager.mm"); +const fabricView = read("ios/Fabric/NativeScriptUIViewComponentView.mm"); + +assert( + index.includes("mountChildrenDirectlyToChildrenView?: boolean") && + index.includes('"mountChildrenDirectlyToChildrenView"') && + index.includes("props.mountChildrenDirectlyToChildrenView === true") && + declarations.includes("mountChildrenDirectlyToChildrenView?: boolean") && + nativeComponent.includes("mountChildrenDirectlyToChildrenView?: boolean") && + hostHeader.includes( + "@property(nonatomic, assign) BOOL mountChildrenDirectlyToChildrenView", + ) && + manager.includes( + "RCT_EXPORT_VIEW_PROPERTY(mountChildrenDirectlyToChildrenView, BOOL)", + ) && + fabricView.includes("oldViewProps->mountChildrenDirectlyToChildrenView") && + fabricView.includes( + "_containerView.mountChildrenDirectlyToChildrenView = newMountChildrenDirectlyToChildrenView;", + ) && + fabricView.includes( + "_containerView.mountChildrenDirectlyToChildrenView = NO;", + ), + "NativeScriptUIView should expose a generic direct Fabric child mount mode for UIKit-owned component views", +); + +assert( + hostView.includes("NativeScriptLayoutHostedSubviewChain(_childrenView, _detachedTouchSentinel, 0);") && + hostView.includes("NativeScriptLayoutMetricsForFabricComponentView") && + hostView.includes("NativeScriptFabricLayoutFrameForView") && + hostView.includes("NativeScriptFabricLayoutSizeForView(parent, &parentLayoutSize)") && + hostView.includes("NativeScriptFabricLayoutSizeForView(child, &childLayoutSize)") && + hostView.includes("if (_layoutDirectChildrenToChildrenViewBounds) {\n NativeScriptLayoutHostedSubviewChain(_childrenView, _detachedTouchSentinel, 0);") && + hostView.includes("if (_layoutDirectChildrenToChildrenViewBounds) {\n NativeScriptLayoutHostedSubviewChain(_childrenView, _detachedTouchSentinel, 0);") && + hostView.includes( + "if (_mountChildrenDirectlyToChildrenView) {\n if (_layoutDirectChildrenToChildrenViewBounds) {", + ) && + hostView.includes( + "if (_mountChildrenDirectlyToChildrenView) {\n if (_layoutDirectChildrenToChildrenViewBounds) {", + ) && + hostView.includes( + "if (_mountChildrenDirectlyToChildrenView) {\n if (_layoutDirectChildrenToChildrenViewBounds) {\n NativeScriptLayoutHostedSubviewChain(_childrenView, _detachedTouchSentinel, 0);\n }\n [self detachDetachedChildrenTouchHandler];\n [self invalidateDetachedChildrenDisplayIfNeeded];", + ) && + hostView.includes( + "if (_childrenView == nil || _mountChildrenDirectlyToChildrenView) {\n return NO;\n }", + ) && + !hostView.includes( + "if (_mountChildrenDirectlyToChildrenView) {\n [self layoutDetachedChildrenViewSubviewsIfNeeded];", + ) && + !hostView.includes( + "if (_mountChildrenDirectlyToChildrenView) {\n [self layoutDetachedChildrenViewSubviewsIfNeeded];", + ), + "Direct child mount mode should mount Fabric children into the supplied UIKit view, avoid detached-host layout/touch-handler repair, and only run direct bounds/layout-metrics repair behind the explicit opt-in flag", +); + +// The hosted fill is a presentation-only override of a box Yoga under-sized. +// Writing the filled frame back into Fabric's cached layout metrics used to +// make that override permanent: Fabric then believed the view was already laid +// out, never applied the real Yoga frame again, and any hosted element with its +// own width/height was pinned to the host's bounds forever. The fill must keep +// Fabric's cache reporting Yoga's truth. +assert( + !hostView.includes("NativeScriptUpdateFabricLayoutMetricsFrameIfPossible") && + !hostView.includes( + 'NSSelectorFromString(@"updateLayoutMetrics:oldLayoutMetrics:")', + ) && + !hostView.includes("nextLayoutMetrics.frame = RCTRectFromCGRect(frame);"), + "The hosted subview fill must not write its filled frame back into Fabric's cached layout metrics, or the real Yoga frame is never applied again and self-sized hosted content is pinned to the host bounds", +); + +// Only re-stretch children Yoga itself stretched flush to the parent's box. A +// child Yoga deliberately sized smaller than its parent is the author's layout +// and must survive the fill untouched. +assert( + hostView.includes( + "return fabs(childLayoutSize.width - parentLayoutSize.width) < 2 &&\n fabs(childLayoutSize.height - parentLayoutSize.height) < 2;", + ), + "NativeScriptSubviewShouldFillParent should compare Yoga boxes on both axes when Yoga laid out both parent and child, so self-sized hosted content keeps its own size", +); + +assert( + hostView.includes("@interface NativeScriptDetachedChildrenLayoutObserver") && + hostView.includes('[_view addObserver:self forKeyPath:@"bounds" options:0 context:nil];') && + hostView.includes('[_view addObserver:self forKeyPath:@"frame" options:0 context:nil];') && + hostView.includes("[owner refreshDetachedChildrenHost];") && + hostView.includes("objc_setAssociatedObject(view, NativeScriptDetachedChildrenLayoutObserverKey, observer") && + hostView.includes("if (_mountChildrenDirectlyToChildrenView && _layoutDirectChildrenToChildrenViewBounds) {\n NativeScriptLayoutHostedSubviewChain(_childrenView, _detachedTouchSentinel, 0);\n }"), + "Direct child bounds layout should refresh when externally-owned UIKit childrenView bounds/frame changes, matching upstream RNS native bounds-driven child layout", +); + +assert( + hostView.includes("- (void)setMountChildrenDirectlyToChildrenView:(BOOL)mountChildrenDirectlyToChildrenView") && + hostView.includes("_mountChildrenDirectlyToChildrenView = mountChildrenDirectlyToChildrenView;") && + hostView.includes("[self detachDetachedChildrenTouchHandler];") && + hostView.includes("[self invalidateDetachedChildrenLayoutSnapshot];") && + hostView.includes("[self invalidateDetachedChildrenDisplaySnapshot];") && + hostView.includes( + "[self moveReactSubviewsToChildrenView];\n [self refreshDetachedChildrenHost];", + ), + "Changing direct child mount mode should clear detached-host state and reparent already-mounted Fabric children before the next transaction", +); + +assert( + hostView.includes("static BOOL NativeScriptHostedOwnerViewPointInsideExcludingHost") && + hostView.includes("static UIView* NativeScriptHostedOwnerViewHitTestExcludingHost") && + hostView.includes("NativeScriptViewIsDescendantOfView(self, hostedView)") && + hostView.includes( + "NativeScriptHostedOwnerViewHitTestExcludingHost(hostedView, self, hostedPoint, event, 0)", + ) && + !hostView.includes( + "NativeScriptViewIsDescendantOfView(self, hostedView)\n ? [hostedView hitTest:hostedPoint withEvent:event]", + ), + "Direct child mount mode should hit-test owner-mounted Fabric children without recursively re-entering the NativeScript host component view", +); + +console.log("uikit host direct children mount API tests passed"); diff --git a/packages/react-native/test/uikit-host-dispose-api.test.js b/packages/react-native/test/uikit-host-dispose-api.test.js index c11bc5f0e..8cacce5e1 100644 --- a/packages/react-native/test/uikit-host-dispose-api.test.js +++ b/packages/react-native/test/uikit-host-dispose-api.test.js @@ -22,7 +22,7 @@ assert( "host adapters should propagate dispose return values", ); -const declarations = read("src/index.d.ts"); +const declarations = read("src/index.ts"); assert( declarations.includes("export type UIKitDisposeResult"), "public declarations should expose UIKitDisposeResult", diff --git a/packages/react-native/test/uikit-host-fabric-lifecycle-api.test.js b/packages/react-native/test/uikit-host-fabric-lifecycle-api.test.js new file mode 100644 index 000000000..eba9bbd54 --- /dev/null +++ b/packages/react-native/test/uikit-host-fabric-lifecycle-api.test.js @@ -0,0 +1,150 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const packageRoot = path.resolve(__dirname, ".."); + +function read(relativePath) { + return fs.readFileSync(path.join(packageRoot, relativePath), "utf8"); +} + +const index = read("src/index.ts"); +const declarations = read("src/index.ts"); +const nativeComponent = read("src/NativeScriptUIViewNativeComponent.ts"); +const hostViewHeader = read("ios/NativeScriptUIView.h"); +const hostView = read("ios/NativeScriptUIView.mm"); +const manager = read("ios/NativeScriptUIViewManager.mm"); +const fabricView = read("ios/Fabric/NativeScriptUIViewComponentView.mm"); +const normalizedIndex = index.replace(/\s+/g, " "); + +assert( + declarations.includes("export type UIKitFabricMountedChild") && + declarations.includes("readonly ownerComponentViewHandle: string") && + declarations.includes("readonly ownerContainerViewHandle: string") && + declarations.includes("readonly ownerNativeViewHandle: string") && + declarations.includes("readonly ownerChildrenViewHandle: string") && + declarations.includes("readonly ownerControllerHandle: string") && + declarations.includes("readonly componentViewHandle: string") && + declarations.includes("readonly containerViewHandle: string") && + declarations.includes("readonly controllerHandle: string") && + declarations.includes("mountingTransactionWillMount?: (") && + declarations.includes("mountingTransactionDidMount?: (") && + declarations.includes("mountChild?: (") && + declarations.includes("unmountChild?: ("), + "public declarations should expose direct Fabric child lifecycle callbacks for UIKit hosts", +); + +assert( + index.includes("function parseUIKitFabricMountedChildJson") && + index.includes('phase === "mountingTransactionWillMount"') && + index.includes('phase === "mountChild" || phase === "unmountChild"') && + index.includes("host.mountChild?.(child, nextProps, host.previousProps)") && + index.includes( + "host.unmountChild?.(child, nextProps, host.previousProps)", + ) && + index.includes("function commitUIKitHostFabricTransaction(") && + normalizedIndex.includes( + "commitUIKitHostFabricTransaction( host, nextProps, host.previousProps, parseUIKitFabricTransactionJson(transactionJson), );", + ) && + index.includes("const hasFabricLifecycleCallbacks =") && + index.includes("transactionCommittedHost != null") && + index.includes("hostReadyHost != null") && + index.includes("nativeMountInfoJson?: string") && + index.includes("parseUIKitNativeMountInfoJson(nativeMountInfoJson)") && + index.includes("fabricLifecycleCallbacks: hasFabricLifecycleCallbacks"), + "defineUIKitHost should route native Fabric lifecycle phases to UI-runtime callbacks and opt in automatically, including transaction/host-ready-only consumers", +); + +assert( + nativeComponent.includes("fabricLifecycleCallbacks?: boolean") && + declarations.includes("fabricLifecycleCallbacks?: boolean") && + index.includes('"fabricLifecycleCallbacks"') && + hostViewHeader.includes( + "@property(nonatomic, assign) BOOL fabricLifecycleCallbacks", + ) && + manager.includes( + "RCT_EXPORT_VIEW_PROPERTY(fabricLifecycleCallbacks, BOOL)", + ), + "NativeScriptUIView should expose an opt-in native prop for Fabric lifecycle callbacks", +); + +assert( + hostViewHeader.includes("- (void)notifyFabricMountingTransactionWillMount") && + hostViewHeader.includes( + "- (void)notifyFabricChildMounted:(UIView*)componentView", + ) && + hostViewHeader.includes( + "- (void)notifyFabricChildUnmounted:(UIView*)componentView", + ) && + hostView.includes('[self runUIKitHostLifecycle:@"mountChild"') && + hostView.includes('[self runUIKitHostLifecycle:@"unmountChild"') && + hostView.includes( + '[self runUIKitHostLifecycle:@"mountingTransactionWillMount"]', + ) && + hostView.includes( + '"ownerComponentViewHandle" : NativeScriptHandleFromNSObject(self.superview)', + ) && + hostView.includes( + '"ownerContainerViewHandle" : NativeScriptHandleFromNSObject(self)', + ) && + hostView.includes( + '"componentViewHandle" : NativeScriptHandleFromNSObject(componentView)', + ) && + hostView.includes( + '"containerViewHandle" : NativeScriptHandleFromNSObject(childContainerView)', + ) && + hostView.includes( + "- (NSArray*>*)fabricMountedChildrenSnapshot", + ), + "NativeScriptUIView should serialize direct Fabric child lifecycle payloads into the UI-runtime host lifecycle", +); + +assert( + fabricView.includes( + "NativeScriptFabricCurrentContainerViewForComponentView", + ) && + read("ios/Fabric/NativeScriptUIViewComponentView.h").includes( + "- (UIView*)nativeScriptCurrentContainerView", + ) && + fabricView.includes( + 'NSSelectorFromString(@"nativeScriptCurrentContainerView")', + ) && + hostView.includes( + 'NSSelectorFromString(@"nativeScriptCurrentContainerView")', + ) && + fabricView.includes("- (UIView*)nativeScriptCurrentContainerView") && + fabricView.includes("return _containerView ?: self;") && + fabricView.includes("if (_containerView.fabricLifecycleCallbacks)") && + fabricView.includes("notifyFabricChildMounted:childComponentView") && + fabricView.includes("notifyFabricChildUnmounted:childComponentView") && + fabricView.includes( + "[_containerView notifyFabricMountingTransactionWillMount]", + ) && + fabricView.includes( + "_containerView.fabricLifecycleCallbacks = newFabricLifecycleCallbacks", + ) && + fabricView.includes("_containerView.fabricLifecycleCallbacks = NO"), + "Fabric component view should forward direct child lifecycle events only for opted-in UIKit hosts", +); + +assert( + hostView.includes("- (void)setFabricLifecycleCallbacks:(BOOL)fabricLifecycleCallbacks") && + hostView.includes("- (NSString*)fabricMountedChildLifecycleKeyForEvent:") && + hostView.includes("- (void)replayFabricMountedChildrenAsMountEventsIfNeeded") && + hostView.includes("NSMutableArray* _fabricMountedChildComponentViews;") && + hostView.includes("- (void)recordFabricChildComponentViewMounted:(UIView*)view index:(NSInteger)index") && + hostView.includes("- (void)recordFabricChildComponentViewUnmounted:(UIView*)view") && + hostView.includes("appendChildren(_fabricMountedChildComponentViews);") && + hostViewHeader.includes("- (void)recordFabricChildComponentViewMounted:(UIView*)view index:(NSInteger)index") && + fabricView.includes("[_containerView recordFabricChildComponentViewMounted:childComponentView index:index];") && + fabricView.includes("[_containerView recordFabricChildComponentViewUnmounted:childComponentView];") && + hostView.includes("[self replayFabricMountedChildrenAsMountEventsIfNeeded];\n [self replayFabricTransactionAfterHostCreationIfNeeded];") && + hostView.includes('if (_hostId.length == 0 || !_fabricLifecycleCallbacks || !_hasCreatedUIKitHost)') && + hostView.includes('[_fabricMountedChildLifecycleKeys addObject:childKey];') && + hostView.includes('[self runUIKitHostLifecycle:@"mountChild" event:event];') && + hostView.includes('if (!_hasCreatedUIKitHost) {\n [_fabricMountedChildLifecycleKeys removeObject:childKey];\n }') && + hostView.includes('[_fabricMountedChildLifecycleKeys removeObject:childKey];'), + "NativeScriptUIView should replay already-mounted direct Fabric children as mountChild lifecycle events once lifecycle callbacks and host creation are ready", +); + +console.log("uikit host Fabric lifecycle API tests passed"); diff --git a/packages/react-native/test/uikit-host-fabric-mount-info-api.test.js b/packages/react-native/test/uikit-host-fabric-mount-info-api.test.js new file mode 100644 index 000000000..ab0d1c9b8 --- /dev/null +++ b/packages/react-native/test/uikit-host-fabric-mount-info-api.test.js @@ -0,0 +1,129 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const packageRoot = path.resolve(__dirname, ".."); + +function read(relativePath) { + return fs.readFileSync(path.join(packageRoot, relativePath), "utf8"); +} + +const index = read("src/index.ts"); +const declarations = read("src/index.ts"); +const hostHeader = read("ios/NativeScriptUIKitHost.h"); +const hostViewHeader = read("ios/NativeScriptUIView.h"); +const hostView = read("ios/NativeScriptUIView.mm"); +const fabricHostView = read("ios/Fabric/NativeScriptUIViewComponentView.mm"); +const nativeApiModule = read("ios/NativeScriptNativeApiModule.mm"); + +assert( + index.includes("export type UIKitNativeMountInfo") && + declarations.includes("export type UIKitNativeMountInfo") && + index.includes("readonly fabricComponentView: unknown | null") && + declarations.includes("readonly fabricComponentView: unknown | null") && + index.includes("requiresNativeMountInfo?: boolean") && + declarations.includes("requiresNativeMountInfo?: boolean") && + index.includes("readonly fabricComponentViewHandle: string") && + index.includes("readonly fabricContainerView: unknown | null") && + index.includes("readonly fabricContainerViewHandle: string"), + "UIKit host context should expose generic Fabric mount handles to UI worklets", +); + +assert( + index.includes("parseUIKitNativeMountInfoJson") && + index.includes('"fabricComponentViewHandle"') && + index.includes('"fabricContainerViewHandle"') && + index.includes("syncUIKitNativeMountInfo(") && + index.includes("pending.nativeMountInfoRef.current = nativeMountInfo") && + index.includes("function nativeObjectFromStringHandle") && + index.indexOf("function nativeObjectFromStringHandle") < + index.indexOf("function parseUIKitNativeMountInfoJson") && + index.includes("pending.requiresNativeMountInfo === true") && + index.includes("pending.nativeMountInfoRef.current == null") && + index.includes( + "const pending = pendingUIKitHostRegistry().get(hostId);", + ) && + index.includes( + "pending?.requiresNativeMountInfo === true &&\n pending.nativeMountInfoRef.current == null", + ) && + index.includes("host?.context.setNativeMountInfo(nativeMountInfo)") && + index.includes("setNativeMountInfo(info)") && + index.includes("nativeMountInfoRef.current = info"), + "UIKit host creation should preserve native Fabric mount info before create() runs and defer opted-in hosts until it exists", +); + +assert( + index.includes( + "shouldRunMountedOrNativeMountInfo: boolean | string = false", + ) && + index.includes("typeof shouldRunMountedOrNativeMountInfo === \"string\"") && + index.includes("parseUIKitNativeMountInfoJson(nativeMountInfoJson)") && + index.includes("createRegisteredUIKitHostFromNative") && + index.includes( + "const handles = createRegisteredUIKitHostFromNative(\n hostId,\n undefined,\n false,\n nativeMountInfoJson,\n );", + ), + "Native-created UIKit hosts should preserve Fabric mount info through both create and lifecycle-created host paths without breaking existing mounted calls", +); + +assert( + hostHeader.includes("NativeScriptCreateUIKitHostWithInfo") && + hostViewHeader.includes("@property(nonatomic, assign) UIView* fabricComponentView") && + nativeApiModule.includes("NSString* nativeMountInfoJson") && + nativeApiModule.includes("nativeMountInfoJsonString") && + nativeApiModule.includes( + "function.call(runtime, hostIdValue, propsJsonValue,\n nativeMountInfoJsonValue)", + ) && + nativeApiModule.includes( + "function.call(runtime, hostIdValue, phaseValue, propsJsonValue,\n transactionJsonValue, nativeMountInfoJsonValue)", + ) && + hostView.includes("- (NSString*)nativeMountInfoJson") && + hostView.includes("UIView* componentView = _fabricComponentView ?: self.superview;") && + hostView.includes("fabricComponentViewHandle") && + hostView.includes("fabricContainerViewHandle") && + fabricHostView.includes("_containerView.fabricComponentView = self;") && + hostView.includes("NativeScriptCreateUIKitHostWithInfo"), + "NativeScriptUIView should pass its Fabric component/container handles into UI worklet host creation", +); + +assert( + hostView.includes("NativeScriptChildrenViewHasVisibleChild(UIView* childrenView,\n UIView* sentinel,\n UIView* owner)") && + hostView.includes("if (subview == owner) {\n if (NativeScriptChildrenViewHasVisibleChild(subview, sentinel, owner))") && + hostView.includes("NativeScriptChildrenViewVisibleDescendantCount(UIView* childrenView,\n UIView* sentinel,\n UIView* owner)") && + hostView.includes("NSUInteger count = (view == sentinel || view == owner) ? 0 : 1;") && + hostView.includes("child == nil || child == self || child == _nativeView") && + hostView.includes("_childrenView == componentView || _nativeView == componentView"), + "Fabric-component-backed hosts should not count their internal NativeScript carrier as user content, but must still inspect descendants inside it", +); + +const refreshContainerViewFrameAndHostIndex = fabricHostView.indexOf( + "- (void)refreshContainerViewFrameAndHost", +); +const refreshContainerViewFrameAndHost = fabricHostView.slice( + refreshContainerViewFrameAndHostIndex, + fabricHostView.indexOf("- (void)scheduleFabricTransactionCommitFallbackIfNeeded"), +); + +assert( + refreshContainerViewFrameAndHostIndex > -1 && + refreshContainerViewFrameAndHost.includes( + "[self refreshContainerViewFrameIfNeeded];", + ) && + refreshContainerViewFrameAndHost.includes( + "[_containerView mountUIKitHostIfNeeded];", + ) && + refreshContainerViewFrameAndHost.indexOf( + "[self refreshContainerViewFrameIfNeeded];", + ) < + refreshContainerViewFrameAndHost.indexOf( + "[_containerView mountUIKitHostIfNeeded];", + ) && + refreshContainerViewFrameAndHost.indexOf( + "[_containerView mountUIKitHostIfNeeded];", + ) < + refreshContainerViewFrameAndHost.indexOf( + "[_containerView refreshDetachedChildrenHost];", + ), + "Fabric component refresh should retry native UIKit host creation after native mount info is available and before child refresh work", +); + +console.log("uikit host Fabric mount info API tests passed"); diff --git a/packages/react-native/test/uikit-host-lifecycle-timing-api.test.js b/packages/react-native/test/uikit-host-lifecycle-timing-api.test.js new file mode 100644 index 000000000..70711dfa4 --- /dev/null +++ b/packages/react-native/test/uikit-host-lifecycle-timing-api.test.js @@ -0,0 +1,102 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const packageRoot = path.resolve(__dirname, ".."); + +function read(relativePath) { + return fs.readFileSync(path.join(packageRoot, relativePath), "utf8"); +} + +const index = read("src/index.ts"); +const nativeView = read("ios/NativeScriptUIView.mm"); +const updateGuard = + "if (nativeViewHandle == null && !mountThroughNativeHost) {"; +const updateGuardIndex = index.indexOf(updateGuard); + +assert( + updateGuardIndex > -1, + "defineUIKitHost should still guard updates until a native host is mounted", +); + +const previousHook = index.slice(0, updateGuardIndex).lastIndexOf("useEffect("); +const previousLayoutHook = index + .slice(0, updateGuardIndex) + .lastIndexOf("useLayoutEffect("); + +assert( + previousLayoutHook > previousHook, + "UIKit host prop updates should run from useLayoutEffect so native library updates are scheduled before passive effects", +); + +assert( + index.includes("function runOnUISync") && + index.includes("const mountThroughNativeHost = true;") && + index.includes("const asyncPreparedHostRef = useRef") && + index.includes("if (mountThroughNativeHost && requiresNativeMountInfo)") && + index.includes("runOnUISync(\n prepareUIKitHostOnUI") && + index.includes("runOnUI(\n prepareUIKitHostOnUI") && + index.includes("replayPendingNativeUIKitHostCreateRequest(hostId)") && + index.includes( + "pendingNativeUIKitHostCreateRequestRegistry().set(hostId", + ) && + index.includes("setNativeHostRevision((revision) => revision + 1)") && + index.includes("mountedRevision:"), + "mount-through-native UIKit hosts should synchronously pre-register native-mount-info hosts, retain async native create replay, and keep mountedRevision explicit", +); + +assert( + !index.includes("__nativeScriptUIKitDefinitionRegistry") && + !index.includes("function registerUIKitDefinition") && + index.includes("const pendingPropsRevision = shouldApplyPendingProps") && + index.includes( + "const latestPropsRef = latest?.propsRef ?? pendingPropsRef;", + ) && + index.includes("propsRevision: nextPropsRevision") && + index.includes("validWorklets") && + index.includes(".runOnUIAsync(installUIKitNativeMountBridge)") && + index.includes("return createImmediately") && + index.includes( + "createRegisteredUIKitHostFromNative(hostId, undefined, false)", + ), + "mount-through-native UIKit hosts should prepare a revision-aware pending host without the failed definition-registry transfer and install the native bridge independently", +); + +assert( + index.includes("disposeRegisteredUIKitHost(hostId, currentProps);"), + "mount-through-native UIKit hosts should dispose from the React layout-effect cleanup with serialized props", +); + +const deallocIndex = nativeView.indexOf("- (void)dealloc {"); +const setHostIdIndex = nativeView.indexOf("- (void)setHostId:"); +assert( + deallocIndex > -1 && setHostIdIndex > deallocIndex, + "NativeScriptUIView should define dealloc before setHostId", +); +const deallocBody = nativeView.slice(deallocIndex, setHostIdIndex); +assert( + !deallocBody.includes("NativeScriptRunUIKitHostLifecycle"), + "NativeScriptUIView dealloc must not re-enter the Worklet runtime during host object finalization", +); +assert( + deallocBody.includes("[self dismissViewControllerPresentationIfNeeded];") && + deallocBody.indexOf("[self dismissViewControllerPresentationIfNeeded];") < + deallocBody.indexOf("[self detachViewControllerIfOwnedByHost];"), + "NativeScriptUIView dealloc should dismiss native UIKit presentations before releasing hosted controllers", +); +assert( + nativeView.includes("- (void)dismissViewControllerPresentationIfNeeded") && + nativeView.includes( + "presentationController.presentingViewController != nil", + ) && + nativeView.includes("dismissViewControllerAnimated:NO completion:nil"), + "NativeScriptUIView should clean up presented controllers with native UIKit dismissal", +); +assert( + nativeView.includes( + 'NativeScriptRunUIKitHostLifecycle(previousHostId, @"dispose", nil)', + ), + "NativeScriptUIView should still dispose the previous host when hostId changes in a stable lifecycle", +); + +console.log("uikit host lifecycle timing API tests passed"); diff --git a/packages/react-native/test/uikit-host-native-props-api.test.js b/packages/react-native/test/uikit-host-native-props-api.test.js new file mode 100644 index 000000000..2288865d1 --- /dev/null +++ b/packages/react-native/test/uikit-host-native-props-api.test.js @@ -0,0 +1,201 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const packageRoot = path.resolve(__dirname, ".."); + +function read(relativePath) { + return fs.readFileSync(path.join(packageRoot, relativePath), "utf8"); +} + +const nativeComponent = read("src/NativeScriptUIViewNativeComponent.ts"); +const index = read("src/index.ts"); +const hostHeader = read("ios/NativeScriptUIKitHost.h"); +const hostViewHeader = read("ios/NativeScriptUIView.h"); +const hostView = read("ios/NativeScriptUIView.mm"); +const fabricView = read("ios/Fabric/NativeScriptUIViewComponentView.mm"); +const nativeApiModule = read("ios/NativeScriptNativeApiModule.mm"); +const manager = read("ios/NativeScriptUIViewManager.mm"); +const normalizedIndex = index.replace(/\s+/g, " "); +const normalizedHostView = hostView.replace(/\s+/g, " "); + +assert( + nativeComponent.includes("uikitHostPropsJson?: string") && + nativeComponent.includes("uikitHostPropsRevision?: Int32"), + "NativeScriptUIView native component should expose a generic host props commit channel", +); + +assert( + index.includes("function stringifySerializableUIKitHostProps") && + index.includes("function stringifyUIKitHostPropsPayload") && + index.includes("__nativeScriptUIKitHostPropsRevision") && + index.includes("__nativeScriptUIKitFunctionProp") && + index.includes("function isSerializableUIKitHostObject") && + index.includes("function copyUIKitHostPropsForUI") && + index.includes('key === "children"') && + index.includes('typeof value === "function"') && + index.includes("!isSerializableUIKitHostObject(value)") && + index.includes("[uikitHostFunctionPropMarkerKey]: true") && + index.includes("uikitHostPropsJson:") && + index.includes("uikitHostPropsRevision:") && + index.includes("updateRevision:") && + index.includes("mountThroughNativeHost && nativeHostPropsJson != null"), + "defineUIKitHost should pass serializable props through Fabric updateRevision", +); +assert( + index.includes("Object.prototype.hasOwnProperty.call(") && + index.includes(' "nativeProps",') && + index.includes('typeof nativePropsMapper === "function"') && + index.includes("const normalizedProps = (props ?? {})") && + index.includes("Object.entries(normalizedProps)") && + index.includes("mappedNativeProps = nativePropsMapper(normalizedProps)") && + index.includes("mappedNativeProps = nativePropsMapper") && + index.includes("Object.assign(nativeProps, mappedNativeProps)"), + "defineUIKitHost should normalize missing props, use own nativeProps mappers, and support explicit static native props", +); + +assert( + index.includes("function syncUIKitHostPropsFromNative") && + index.includes("JSON.parse(propsJson)") && + index.includes("function mergeUIKitHostPropsFromNative") && + index.includes("isUIKitHostFunctionPropMarker(nativeValue)") && + index.includes("mergeUIKitHostPropsFromNative(current, nativeProps)") && + index.includes("function shouldApplyUIKitHostPropsRevision") && + index.includes("pending.propsRevision") && + index.includes("host.propsRevision") && + index.includes("syncUIKitHostPropsFromNative(hostId, propsJson)") && + index.includes("shouldRunMountedOrNativeMountInfo: boolean | string = false") && + normalizedIndex.includes( + "createRegisteredUIKitHostFromNative( hostId, undefined, false, nativeMountInfoJson, )", + ) && + !index.includes("createRegisteredUIKitHostFromNative(hostId, propsJson)"), + "UI worklet host lifecycle should merge native-commit props once before running updates", +); +assert( + index.indexOf("function syncUIKitHostPropsFromNative") < + index.indexOf("function createRegisteredUIKitHostFromNative") && + index.indexOf("function syncUIKitHostPropsFromNative") < + index.indexOf("function runUIKitHostLifecycleFromNative"), + "native-props worklet helper must be declared before worklets capture it", +); + +assert( + index.includes("function hasNonSerializableUIKitHostProps") && + index.includes("function nonSerializableUIKitHostPropsChanged") && + index.includes("const reactHostPropsJsonRef") && + index.includes("didLiveHostPropsChange") && + index.includes("reactHostPropsRevisionRef.current += 1") && + index.includes("reactHostPropsJsonRef.current = nextSerializableReactHostPropsJson") && + index.includes("nextRevision > currentRevision") && + index.includes("syncUIKitHostPropsFromReact(") && + index.includes("nextPropsRevision") && + // Lever 2 (pop-wedge fix): host.update()/commitUIKitHostFabricTransaction + // must gate on whether the SERIALIZABLE native payload actually advanced + // (nativeRevisionAdvanced, compared against host.updateAppliedNativeRevision), + // not merely on "this host has function props at all" (the old + // shouldUpdateFromReactProps check, which fired on every function-identity-only + // re-render even with zero real prop change). + index.includes("nativeRevisionAdvanced") && + index.includes("host.updateAppliedNativeRevision") && + index.includes("updateAppliedNativeRevision?: number") && + index.includes("const uiRuntimeProps = copyUIKitHostPropsForUI(pluginProps)") && + index.includes("prepareUIKitHostOnUI,\n uiRuntimeProps,") && + index.includes("host.update?.(") && + index.includes("host.previousProps = nextProps") && + index.includes("didApplyProps") && + index.includes("return uikitHostHandles(host);"), + "mount-through-native updates should rerun on the UI thread only when the serializable native payload actually advanced, not on function-identity-only churn", +); + +assert( + index.includes("function runOnUISync") && + !index.includes("export function runOnUISync") && + index.includes('typeof worklets.runOnUISync !== "function"') && + index.includes("return worklets.runOnUISync(callback, ...args);"), + "NativeScript should keep a synchronous UI worklet primitive (internal) for Fabric-style native host preparation", +); + +assert( + index.includes("const [nativeHostRevision, setNativeHostRevision]") && + index.includes("const prepareUIKitHostOnUI = (") && + index.includes("createRegisteredUIKitHostFromNative(hostId, undefined, false)") && + index.includes("setNativeHostRevision((revision) => revision + 1)") && + index.includes("mountedRevision:") && + index.includes("nativeHostRevision > 0"), + "mount-through-native hosts should keep the mountedRevision fallback visible until NativeScript has a main-thread synchronous UI worklet primitive", +); + +assert( + index.includes("if (shouldRunMounted && !host.hasMounted)") && + index.includes('phase === "mounted" && !host.hasMounted') && + index.includes("host.hasMounted = true;") && + index.includes("host.mounted?.(host.propsRef.current);"), + "native-created UIKit hosts should run mounted idempotently from the native mounted lifecycle", +); + +assert( + index.includes("reactHostPropsRevision,") && + !index.includes(" pluginProps,\n updateHost,"), + "defineUIKitHost should depend on the native-relevant props revision instead of the fresh pluginProps object", +); + +const declarations = read("src/index.ts"); +assert( + declarations.includes("nativeProps?:\n | Partial") && + declarations.includes(") => Partial | undefined);"), + "public declarations should allow function or explicit static nativeProps definitions", +); + +assert( + hostHeader.includes("NativeScriptCreateUIKitHost(") && + hostHeader.includes("NSString* hostId, NSString* propsJson") && + hostHeader.includes("NSString* hostId, NSString* phase, NSString* propsJson"), + "native host bridge should accept a generic props snapshot", +); + +assert( + hostViewHeader.includes("@property(nonatomic, copy) NSString* uikitHostPropsJson") && + hostView.includes("NativeScriptCreateUIKitHostWithInfo(") && + hostView.includes("NSString* nativeMountInfoJson = [self nativeMountInfoJson];") && + normalizedHostView.includes( + "NativeScriptCreateUIKitHostWithInfo( _hostId, _uikitHostPropsJson, nativeMountInfoJson)" + ) && + hostView.includes("BOOL _hasCreatedUIKitHost;") && + hostView.includes("_hasCreatedUIKitHost = NO;") && + hostView.includes("_hasCreatedUIKitHost = YES;") && + hostView.includes("_hostId.length == 0 || _hasCreatedUIKitHost") && + hostView.includes("NativeScriptRunUIKitHostLifecycleWithInfo(") && + normalizedHostView.includes( + "NativeScriptRunUIKitHostLifecycleWithInfo(_hostId, phase, _uikitHostPropsJson, transactionJson, [self nativeMountInfoJson])" + ) && + normalizedHostView.includes( + "NativeScriptRunUIKitHostLifecycleWithInfo(_hostId, phase, _uikitHostPropsJson, nil, [self nativeMountInfoJson])" + ) && + hostView.indexOf("NativeScriptRunUIKitHostLifecycleWithInfo(") > + hostView.indexOf("- (void)runUIKitHostLifecycle:"), + "NativeScriptUIView should forward latest props and native mount info while avoiding already-mounted native host recreation before every lifecycle call", +); + +assert( + fabricView.includes("newViewProps->uikitHostPropsJson") && + fabricView.includes("_containerView.uikitHostPropsJson = uikitHostPropsJson") && + fabricView.includes("_containerView.uikitHostPropsRevision = newUIKitHostPropsRevision") && + fabricView.indexOf("_containerView.uikitHostPropsJson = uikitHostPropsJson") < + fabricView.indexOf("_containerView.hostId = hostId"), + "Fabric component view should apply host props before hostId/updateRevision lifecycle props", +); + +assert( + nativeApiModule.includes("propsJsonString") && + nativeApiModule.includes("function.call(runtime, hostIdValue, propsJsonValue)") && + nativeApiModule.includes("function.call(runtime, hostIdValue, phaseValue, propsJsonValue)"), + "native module should pass props snapshots into the UI worklet runtime synchronously", +); + +assert( + manager.includes("RCT_EXPORT_VIEW_PROPERTY(uikitHostPropsJson, NSString)") && + manager.includes("RCT_EXPORT_VIEW_PROPERTY(uikitHostPropsRevision, NSInteger)"), + "Paper host manager should expose the generic props commit channel too", +); + +console.log("uikit host native props API tests passed"); diff --git a/packages/react-native/test/uikit-host-ready-api.test.js b/packages/react-native/test/uikit-host-ready-api.test.js index 0d31ff9b8..e26991008 100644 --- a/packages/react-native/test/uikit-host-ready-api.test.js +++ b/packages/react-native/test/uikit-host-ready-api.test.js @@ -8,6 +8,15 @@ function read(relativePath) { return fs.readFileSync(path.join(packageRoot, relativePath), 'utf8'); } +// Strip C/ObjC/TS comments so "must not ship RNS-specific hooks" assertions +// match against shipped code rather than RNS-parity documentation, which +// legitimately cites upstream sources (e.g. "RNS parity (RNSScreen.mm:155)"). +function stripComments(source) { + return source + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/[^\n]*/g, ''); +} + const nativeComponent = read('src/NativeScriptUIViewNativeComponent.ts'); assert( nativeComponent.includes('DirectEventHandler'), @@ -17,6 +26,14 @@ assert( nativeComponent.includes('hostReadyId?: string'), 'NativeScriptUIViewNativeComponent should expose a stable readiness identity prop', ); +assert( + nativeComponent.includes('emitOffWindowHostReady?: boolean'), + 'NativeScriptUIViewNativeComponent should expose explicit off-window host-ready policy', +); +assert( + nativeComponent.includes('ignoreHostReadyWindowAttachment?: boolean'), + 'NativeScriptUIViewNativeComponent should expose window-attachment host-ready dedupe policy', +); assert( nativeComponent.includes('onHostReady?: DirectEventHandler'), 'NativeScriptUIViewNativeComponent should expose onHostReady', @@ -25,8 +42,17 @@ assert( nativeComponent.includes('hasChildren: boolean'), 'onHostReady should report whether RN children are attached', ); +assert( + nativeComponent.includes('componentViewHandle: string'), + 'onHostReady should expose the Fabric component view handle', +); +assert( + nativeComponent.includes('visibleDescendantCount: Int32') && + nativeComponent.includes('windowAttached: boolean'), + 'onHostReady should report windowed/deep descendant readiness', +); -const declarations = read('src/index.d.ts'); +const declarations = read('src/index.ts'); assert( declarations.includes('export type UIKitHostReadyEvent'), 'public declarations should export UIKitHostReadyEvent', @@ -35,6 +61,23 @@ assert( declarations.includes('onHostReady?: (event: UIKitHostReadyEvent) => void'), 'public host props should expose onHostReady', ); +assert( + declarations.includes('emitOffWindowHostReady?: boolean'), + 'public host props should expose explicit off-window host-ready policy', +); +assert( + declarations.includes('ignoreHostReadyWindowAttachment?: boolean'), + 'public host props should expose window-attachment host-ready dedupe policy', +); +assert( + declarations.includes('componentViewHandle: string'), + 'public host-ready event should expose the Fabric component view handle', +); +assert( + declarations.includes('hostReady?: (') && + declarations.includes('event: UIKitHostReadyEvent'), + 'UIKit host definitions should expose a UI-worklet hostReady lifecycle', +); const index = read('src/index.ts'); assert( @@ -45,6 +88,25 @@ assert( index.includes('onHostReady'), 'defineUIKitHost should forward onHostReady to NativeScriptUIView', ); +assert( + index.includes('const emitOffWindowHostReady = props.emitOffWindowHostReady === true') && + index.includes('emitOffWindowHostReady,'), + 'defineUIKitHost should forward explicit off-window host-ready policy to NativeScriptUIView', +); +assert( + index.includes( + 'const ignoreHostReadyWindowAttachment =\n props.ignoreHostReadyWindowAttachment === true;', + ) && index.includes('ignoreHostReadyWindowAttachment,'), + 'defineUIKitHost should forward host-ready window-attachment dedupe policy to NativeScriptUIView', +); +assert( + index.includes('const hostReadyHost = definition.hostReady') && + index.includes('function parseUIKitHostReadyEventJson') && + index.includes('phase === "hostReady"') && + index.includes('host.hostReady?.(nextProps, hostReadyEvent, host.previousProps)') && + index.includes('hostReadyHost?.('), + 'defineUIKitHost should dispatch host-ready directly through the UI-worklet lifecycle', +); const header = read('ios/NativeScriptUIView.h'); assert( @@ -55,6 +117,16 @@ assert( header.includes('onHostReady'), 'NativeScriptUIView should expose a Paper host-ready event block', ); +assert( + header.includes('@property(nonatomic, assign) BOOL emitOffWindowHostReady'), + 'NativeScriptUIView should store explicit off-window host-ready policy', +); +assert( + header.includes( + '@property(nonatomic, assign) BOOL ignoreHostReadyWindowAttachment', + ), + 'NativeScriptUIView should store host-ready window-attachment dedupe policy', +); const manager = read('ios/NativeScriptUIViewManager.mm'); assert( @@ -65,6 +137,16 @@ assert( manager.includes('RCT_EXPORT_VIEW_PROPERTY(onHostReady, RCTDirectEventBlock)'), 'Paper manager should export onHostReady', ); +assert( + manager.includes('RCT_EXPORT_VIEW_PROPERTY(emitOffWindowHostReady, BOOL)'), + 'Paper manager should export explicit off-window host-ready policy', +); +assert( + manager.includes( + 'RCT_EXPORT_VIEW_PROPERTY(ignoreHostReadyWindowAttachment, BOOL)', + ), + 'Paper manager should export host-ready window-attachment dedupe policy', +); const fabricView = read('ios/Fabric/NativeScriptUIViewComponentView.mm'); assert( @@ -75,5 +157,140 @@ assert( fabricView.includes('onHostReady('), 'Fabric component should emit onHostReady', ); +assert( + fabricView.includes('NSDictionary* _pendingHostReadyEvent;') && + fabricView.includes('if (_eventEmitter == nullptr)') && + fabricView.includes('_pendingHostReadyEvent = [event copy];') && + fabricView.includes('- (void)updateEventEmitter:(const EventEmitter::Shared&)eventEmitter') && + fabricView.includes('[self emitHostReadyEvent:event];'), + 'Fabric component should replay hostReady events emitted before Fabric installs an event emitter', +); +assert( + fabricView.includes('.visibleDescendantCount = [event[@"visibleDescendantCount"] intValue]') && + fabricView.includes('.windowAttached = [event[@"windowAttached"] boolValue]'), + 'Fabric host-ready events should forward window/deep-descendant readiness', +); +assert( + fabricView.includes( + '.componentViewHandle = RCTStringFromNSString(event[@"componentViewHandle"] ?: @""),', + ), + 'Fabric host-ready events should forward the component view handle', +); +assert( + fabricView.includes('oldViewProps->emitOffWindowHostReady') && + fabricView.includes('_containerView.emitOffWindowHostReady = newEmitOffWindowHostReady;') && + fabricView.includes('_containerView.emitOffWindowHostReady = NO;'), + 'Fabric component should forward and recycle explicit off-window host-ready policy', +); +assert( + fabricView.includes('oldViewProps->ignoreHostReadyWindowAttachment') && + fabricView.includes( + '_containerView.ignoreHostReadyWindowAttachment =\n newIgnoreHostReadyWindowAttachment;', + ) && + fabricView.includes('_containerView.ignoreHostReadyWindowAttachment = NO;'), + 'Fabric component should forward and recycle host-ready window-attachment dedupe policy', +); + +const hostView = read('ios/NativeScriptUIView.mm'); +const nativeApiModule = read('ios/NativeScriptNativeApiModule.mm'); +const runtimeSource = read('src/index.ts'); +const hostViewCode = stripComments(hostView); +const nativeApiModuleCode = stripComments(nativeApiModule); +const runtimeSourceCode = stripComments(runtimeSource); +assert( + // NS_RNS_TRACE (trace macro) and NSRNS (RNS symbol prefix) must never appear + // at all -- not even in comments -- since they only exist to ship a hook. + !hostView.includes('NS_RNS_TRACE') && + !hostView.includes('NSRNS') && + !nativeApiModule.includes('NS_RNS_TRACE') && + !nativeApiModule.includes('NSRNS') && + !runtimeSource.includes('NS_RNS_TRACE') && + !runtimeSource.includes('NSRNS') && + // The RNSScreen* classes must not be referenced by shipped code, but the + // parity-documented sources legitimately cite RNSScreen.mm in comments; + // check only the comment-stripped code for a live class reference. + !hostViewCode.includes('RNSScreen') && + !nativeApiModuleCode.includes('RNSScreen') && + !runtimeSourceCode.includes('RNSScreen'), + 'Generic UIKit host runtime should not ship react-native-screens-specific trace hooks', +); +assert( + runtimeSource.includes('__nativeScriptUIKitHostTraceEvents') && + runtimeSource.includes('[NativeScript UIKitHost]'), + 'UIKit host runtime tracing should use generic NativeScript naming', +); +const lifecycleIndex = hostView.indexOf( + '[self runUIKitHostLifecycle:@"hostReady" transactionJson:eventJson];', +); +const eventBlockIndex = hostView.indexOf('if (_onHostReady != nil)'); +assert( + lifecycleIndex >= 0 && lifecycleIndex < eventBlockIndex, + 'NativeScriptUIView should notify UI-worklet hostReady before the React onHostReady event', +); +assert( + hostView.includes('NativeScriptChildrenViewVisibleDescendantCount') && + hostView.includes('event[@"componentViewHandle"] = NativeScriptHandleFromNSObject(self.superview);') && + hostView.includes('event[@"visibleDescendantCount"] = @(visibleDescendantCount);') && + hostView.includes('event[@"windowAttached"] = @(attachedWindow != nil);') && + hostView.includes( + 'return _childrenView.window ?: _nativeView.window ?: _viewController.view.window ?: self.window;', + ) && + hostView.includes('[event[@"windowAttached"] boolValue] ? @"1" : @"0"') && + hostView.includes('event[@"visibleDescendantCount"] ?: @(0)'), + 'NativeScriptUIView should re-emit host-ready when window/deep descendant readiness changes without handle changes', +); +assert( + index.includes('componentViewHandle: stringValue(event.componentViewHandle)'), + 'UI-worklet host-ready parser should expose componentViewHandle', +); +assert( + hostView.includes( + 'event[@"nativeViewHandle"] =\n _nativeView != nil ? NativeScriptHandleFromNSObject(_nativeView) : (_nativeViewHandle ?: @"");', + ) && + hostView.includes( + '_nativeView != nil ? NativeScriptHandleFromNSObject(_nativeView)\n : (_nativeViewHandle ?: @"")', + ) && + hostView.includes( + '@"nativeViewHandle" :\n _nativeView != nil ? NativeScriptHandleFromNSObject(_nativeView) : (_nativeViewHandle ?: @""),', + ), + 'NativeScriptUIView should publish the stored native view handle when a controller view is intentionally detached from the host wrapper', +); +assert( + hostView.includes('NSString* _lastHostReadyShallowKey') && + hostView.includes('NativeScriptAppendSubviewTopology') && + hostView.includes( + '- (NSString*)hostReadyShallowKeyWithHasChildren:(BOOL)hasChildren\n attachedWindow:(UIWindow*)attachedWindow', + ) && + hostView.includes( + 'if (_lastHostReadyKey != nil && [_lastHostReadyShallowKey isEqualToString:shallowKey])', + ) && + hostView.includes('_lastHostReadyShallowKey = [shallowKey copy];'), + 'NativeScriptUIView should skip duplicate host-ready deep descendant walks when shallow host topology is unchanged', +); +const shallowKeyStart = hostView.indexOf( + '- (NSString*)hostReadyShallowKeyWithHasChildren:', +); +const shallowKeyEnd = hostView.indexOf('- (void)notifyHostReadyIfNeeded', shallowKeyStart); +const shallowKeySource = hostView.slice(shallowKeyStart, shallowKeyEnd); +assert( + !shallowKeySource.includes('NativeScriptHandleFromNSObject(self.superview)'), + 'NativeScriptUIView should not re-emit host-ready just because UIKit reattached the Fabric wrapper under a different same-window superview', +); +assert( + hostView.includes('UIWindow* attachedWindow = [self hostReadyAttachedWindow];') && + hostView.includes('if (attachedWindow == nil && !_emitOffWindowHostReady)') && + hostView.includes('attachedWindow:attachedWindow'), + 'NativeScriptUIView should suppress transient detached host-ready events unless the host explicitly opts in', +); +assert( + hostView.includes( + '- (void)setIgnoreHostReadyWindowAttachment:(BOOL)ignoreHostReadyWindowAttachment', + ) && + hostView.includes( + 'void* windowKey = _ignoreHostReadyWindowAttachment ? NULL : (void*)attachedWindow;', + ) && + hostView.includes('windowKey];'), + 'NativeScriptUIView should be able to dedupe host-ready snapshots across window attach for hosts that already certified off-window content', +); console.log('uikit host ready API tests passed'); diff --git a/packages/react-native/test/uikit-host-refresh-api.test.js b/packages/react-native/test/uikit-host-refresh-api.test.js index a13014513..2bf45f1ae 100644 --- a/packages/react-native/test/uikit-host-refresh-api.test.js +++ b/packages/react-native/test/uikit-host-refresh-api.test.js @@ -9,6 +9,7 @@ function read(relativePath) { } const index = read("src/index.ts"); +const nativeComponent = read("src/NativeScriptUIViewNativeComponent.ts"); assert( index.includes("export function refreshUIKitHostView"), "public JS API should export refreshUIKitHostView", @@ -18,20 +19,41 @@ assert( "refreshUIKitHostView should call the worklet-installed native refresh global", ); assert( - index.includes("export function refreshUIKitHostViewHandle") && - index.includes("return refresh(nativeHandleForUIKitView(view)) === true;") && - index.includes("return refresh(viewHandle) === true;"), - "public JS API should refresh UIKit hosts from native handles", + index.includes("export function flushUIKitHostView") && + index.includes("__nativeScriptFlushUIKitHostView"), + "public JS API should export flushUIKitHostView (used by the tabs-snapshot reveal path, symmetric with refreshUIKitHostView)", +); +assert( + index.includes("export function notifyUIKitAccessibilityLayoutChanged") && + index.includes("__nativeScriptNotifyUIKitAccessibilityLayoutChanged") && + !index.includes( + "export function notifyUIKitAccessibilityLayoutChangedHandle", + ), + "public JS API should expose UIKit accessibility layout invalidation (unused handle variant trimmed)", +); +assert( + !index.includes("refreshUIKitHostViewHandle") && + !index.includes("refreshUIKitHostViewOwner") && + !index.includes("refreshUIKitHostViewDirectOwner") && + !index.includes("invalidateUIKitHostReadyOwner") && + !index.includes("flushUIKitHostViewHandle") && + !index.includes("flushUIKitHostViewOwner") && + !index.includes("attachViewControllerToNearestParent") && + !index.includes("nearestViewController"), + "unused UIKit host refresh/flush/invalidate/attach handle+owner variants should be trimmed from the JS surface (base refresh/flush + native entry points are retained)", ); -const declarations = read("src/index.d.ts"); +const declarations = read("src/index.ts"); assert( declarations.includes("refreshUIKitHostView(view: unknown): boolean"), "public declarations should expose refreshUIKitHostView", ); assert( - declarations.includes("refreshUIKitHostViewHandle(viewHandle: string): boolean"), - "public declarations should expose handle-based UIKit host refresh", + declarations.includes( + "notifyUIKitAccessibilityLayoutChanged(view: unknown): boolean", + ) && + !declarations.includes("notifyUIKitAccessibilityLayoutChangedHandle("), + "public declarations should expose UIKit accessibility layout invalidation (unused handle variant trimmed)", ); const hostHeader = read("ios/NativeScriptUIKitHost.h"); @@ -39,8 +61,63 @@ assert( hostHeader.includes("NativeScriptRefreshUIKitHostView"), "UIKit host header should export a native refresh entry point", ); +assert( + hostHeader.includes("NativeScriptRefreshUIKitHostViewOwner"), + "UIKit host header should export an owner-only native refresh entry point", +); +assert( + hostHeader.includes("NativeScriptRefreshUIKitHostViewDirectOwner"), + "UIKit host header should export a direct-owner native refresh entry point", +); +assert( + hostHeader.includes("NativeScriptInvalidateUIKitHostReadyOwner"), + "UIKit host header should export an owner-only native hostReady invalidation entry point", +); +assert( + hostHeader.includes("NativeScriptNotifyUIKitAccessibilityLayoutChanged"), + "UIKit host header should export a native accessibility layout invalidation entry point", +); +assert( + hostHeader.includes("NativeScriptFlushUIKitHostView") && + hostHeader.includes("NativeScriptFlushUIKitHostViewOwner"), + "UIKit host header should export native display-flush entry points", +); +assert( + hostHeader.includes("NativeScriptAttachViewControllerToNearestParent"), + "UIKit host header should export generic nearest-parent UIViewController attachment", +); +assert( + hostHeader.includes("NativeScriptNearestViewControllerForView"), + "UIKit host header should export generic nearest UIViewController lookup", +); const hostView = read("ios/NativeScriptUIView.mm"); +const hostViewHeader = read("ios/NativeScriptUIView.h"); +const manager = read("ios/NativeScriptUIViewManager.mm"); +const fabricView = read("ios/Fabric/NativeScriptUIViewComponentView.mm"); +assert( + hostHeader.includes("BOOL allowRootParent") && + hostView.includes( + "if (!allowRootParent && parent == view.window.rootViewController)", + ), + "nearest-parent UIViewController attachment should refuse app-root parenting unless explicitly requested", +); +assert( + hostView.includes("NativeScriptNearestViewControllerForView") && + hostView.includes( + "NativeScriptClosestReactViewControllerForView(view, nil)", + ) && + hostView.includes( + "controller = NativeScriptNearestViewController(view, nil);", + ) && + hostView.includes( + "NativeScriptClosestReactViewControllerForView(view, controller)", + ) && + hostView.includes( + "parent = NativeScriptNearestResponderViewController(view, controller);", + ), + "nearest UIViewController lookup should mirror RN reactViewController ownership before falling back to UIKit parent resolution", +); assert( hostView.includes("#import "), "NativeScriptUIView should use ObjC associations for detached children hosts", @@ -49,6 +126,121 @@ assert( hostView.includes("refreshDetachedChildrenHost"), "NativeScriptUIView should be able to refresh detached React children", ); +assert( + hostView.includes("UIWindow* _lastUIKitHostAttachmentWindow;") && + hostView.includes("BOOL _needsUIKitHostRefreshAfterNativeAttachment;") && + hostView.includes("if (_hostId.length == 0 ||\n _disableUIKitHostWindowAttachRefresh ||") && + hostView.includes("UIWindow* currentWindow = self.window;") && + hostView.includes( + "if (!_needsUIKitHostRefreshAfterNativeAttachment &&\n _lastUIKitHostAttachmentWindow == currentWindow)", + ) && + hostView.includes("_lastUIKitHostAttachmentWindow = currentWindow;") && + hostView.includes("_needsUIKitHostRefreshAfterNativeAttachment = NO;") && + hostView.includes("- (void)setNeedsUIKitHostRefreshAfterNativeAttachment"), + "NativeScriptUIView should refresh UIKit hosts only on real window or dirty host attachment changes", +); +assert( + nativeComponent.includes("disableUIKitHostWindowAttachRefresh?: boolean") && + declarations.includes("disableUIKitHostWindowAttachRefresh?: boolean") && + index.includes('"disableUIKitHostWindowAttachRefresh"') && + index.includes( + "const disableUIKitHostWindowAttachRefresh =\n props.disableUIKitHostWindowAttachRefresh === true;", + ) && + index.includes("disableUIKitHostWindowAttachRefresh,") && + hostViewHeader.includes( + "@property(nonatomic, assign) BOOL disableUIKitHostWindowAttachRefresh", + ) && + manager.includes( + "RCT_EXPORT_VIEW_PROPERTY(disableUIKitHostWindowAttachRefresh, BOOL)", + ) && + fabricView.includes("oldViewProps->disableUIKitHostWindowAttachRefresh") && + fabricView.includes( + "_containerView.disableUIKitHostWindowAttachRefresh =\n newDisableUIKitHostWindowAttachRefresh;", + ) && + fabricView.includes("_containerView.disableUIKitHostWindowAttachRefresh = NO;"), + "UIKit hosts should expose an opt-out for generic window-attachment refresh when native containment owns the hot path", +); +assert( + hostView.includes("- (void)refreshDetachedChildrenSentinelAttachment") && + hostView.includes("[self.owner refreshDetachedChildrenSentinelAttachment];") && + !hostView.includes("[self.owner refreshDetachedChildrenHost];"), + "detached children sentinel callbacks should maintain attachment without emitting hostReady lifecycle", +); +assert( + hostView.includes("static BOOL NativeScriptInvalidateHostReadyOwner") && + hostView.includes("[owner invalidateHostReadySnapshot];") && + hostView.includes("[owner notifyHostReadyIfNeeded];") && + hostView.includes("BOOL _isNotifyingHostReady;") && + hostView.includes("static BOOL isDeliveringHostReady;") && + hostView.includes("if (isDeliveringHostReady)") && + hostView.includes("if (_isNotifyingHostReady)") && + hostView.includes("_isNotifyingHostReady = YES;") && + hostView.includes("isDeliveringHostReady = YES;") && + hostView.includes("isDeliveringHostReady = NO;") && + hostView.includes("@finally {\n isDeliveringHostReady = NO;\n _isNotifyingHostReady = NO;\n }") && + hostView.includes("BOOL NativeScriptInvalidateUIKitHostReadyOwner"), + "owner hostReady invalidation should clear the native snapshot and re-emit the UI-worklet lifecycle", +); +assert( + hostView.includes("BOOL NativeScriptNotifyUIKitAccessibilityLayoutChanged") && + hostView.includes("UIAccessibilityPostNotification") && + hostView.includes("UIAccessibilityLayoutChangedNotification"), + "accessibility layout invalidation should notify UIKit accessibility synchronously on the main thread", +); +assert( + hostView.includes( + "static BOOL NativeScriptRefreshOwner(NativeScriptUIView* owner)", + ) && + hostView.includes("static NSMutableSet* refreshingOwners") && + hostView.includes("[refreshingOwners containsObject:ownerKey]") && + hostView.includes("[refreshingOwners addObject:ownerKey]") && + hostView.includes("@finally {\n [refreshingOwners removeObject:ownerKey];\n }") && + hostView.includes("[owner attachViewControllerIfPossible]") && + hostView.includes('[owner runUIKitHostLifecycle:@"refresh"\n transactionJson:') && + hostView.includes("refreshedDetachedChildren = [owner refreshDetachedChildrenHost];") && + hostView.includes("return refreshedDetachedChildren;") && + !hostView.includes( + "- (BOOL)refreshDetachedChildrenHost {\n [self attachViewControllerIfPossible];", + ), + "explicit refreshUIKitHostView should retry generic UIViewController containment without mutating containment from hitTest refreshes", +); +assert( + hostView.includes("NativeScriptUIView* owner = NativeScriptUIKitHostOwnerForView(view);") && + hostView.includes("if (owner != nil) {\n return NativeScriptRefreshOwner(owner);\n }\n\n return NativeScriptRefreshUIKitHostSubviews(view, 0);") && + !hostView.includes( + "return NativeScriptRefreshUIKitHostOwnersInAncestorChain(view) ||\n NativeScriptRefreshUIKitHostSubviews(view, 0);", + ), + "generic refreshUIKitHostView should refresh the direct hosted owner/tree instead of re-entering ancestor stack owners", +); +assert( + index.includes('phase === "refresh"') && + index.includes("function refreshingUIKitHostSet") && + index.includes("refreshingHosts.add(hostId)") && + index.includes("refreshingHosts.delete(hostId)") && + index.includes("const refreshHost = definition.refresh") && + index.includes("host.refresh?.(nextProps, host.previousProps);") && + index.includes("refreshHost?.("), + "explicit refreshUIKitHostView should run only an opted-in guarded host refresh when UIKit moves hosted views without React prop changes", +); +assert( + declarations.includes("refresh?: (") && + declarations.includes( + '"create" | "update" | "refresh" | "mounted" | "dispose"', + ), + "public declarations should expose the explicit UIKit host refresh callback", +); +assert( + hostView.includes( + "sameHandle && (_nativeView != nil || _nativeViewHandle.length == 0)", + ) && + hostView.includes( + "sameHandle && (_childrenView != nil || _childrenViewHandle.length == 0)", + ) && + hostView.includes( + "sameHandle && (_viewController != nil || _controllerHandle.length == 0)", + ), + "NativeScriptUIView should retry same-handle resolution when native objects were not resolvable yet", +); assert( hostView.includes("NativeScriptDetachedChildrenOwner") && hostView.includes("objc_setAssociatedObject") && @@ -60,22 +252,117 @@ assert( hostView.includes("refreshDetachedChildrenHost"), "refreshUIKitHostView should refresh a detached children view even if its sentinel was removed", ); +assert( + hostView.includes("BOOL NativeScriptRefreshUIKitHostViewOwner") && + hostView.includes( + "return NativeScriptRefreshUIKitHostOwnersInAncestorChain(view);", + ) && + !hostView.includes( + "BOOL NativeScriptRefreshUIKitHostViewOwner(NSString* viewHandle) {\n if (![NSThread isMainThread]) {\n return NO;\n }\n\n UIView* view = NativeScriptUIViewFromHandle(viewHandle);\n if (view == nil) {\n return NO;\n }\n\n return NativeScriptRefreshUIKitHostOwnersInAncestorChain(view) ||\n NativeScriptRefreshUIKitHostSubviews(view, 0);", + ), + "owner-only refresh should not recursively scan every hosted React descendant", +); +assert( + hostView.includes("BOOL NativeScriptRefreshUIKitHostViewDirectOwner") && + hostView.includes( + "return NativeScriptRefreshOwner(NativeScriptUIKitHostOwnerForView(view));", + ) && + !hostView.includes( + "BOOL NativeScriptRefreshUIKitHostViewDirectOwner(NSString* viewHandle) {\n if (![NSThread isMainThread]) {\n return NO;\n }\n\n UIView* view = NativeScriptUIViewFromHandle(viewHandle);\n if (view == nil) {\n return NO;\n }\n\n return NativeScriptRefreshUIKitHostOwnersInAncestorChain(view);", + ), + "direct-owner refresh should refresh only the nearest UIKit host owner without walking ancestor owners", +); +assert( + hostView.includes("NSArray* subviews = [root.subviews copy];") && + hostView.includes("for (UIView* subview in subviews)") && + hostView.includes("[subviews release];"), + "refreshUIKitHostView should snapshot subviews because refreshing owners can mutate UIKit hierarchy during traversal", +); +assert( + hostView.includes("NSString* _lastDetachedChildrenLayoutKey") && + hostView.includes("NativeScriptDetachedChildrenLayoutSnapshotKey") && + hostView.includes('appendString:@"|tree:"') && + hostView.includes("NativeScriptAppendSubviewTopology(key, childrenView, sentinel, 0, 3)") && + hostView.includes( + "if ([_lastDetachedChildrenLayoutKey isEqualToString:layoutKey])", + ) && + hostView.includes( + "- (BOOL)layoutDetachedChildrenViewSubviewsAndReturnMutation", + ), + "NativeScriptUIView refresh should skip duplicate detached-children layout snapshots", +); +assert( + hostView.includes("if ([parent isKindOfClass:UIScrollView.class])") && + hostView.includes("childFrame.size.height < parentBounds.size.height - 2") && + hostView.includes("static CGRect NativeScriptHostedSubviewFillFrame(UIView* parent)") && + hostView.includes("frame.origin = CGPointZero;") && + hostView.includes("const CGRect bounds = NativeScriptHostedSubviewFillFrame(root);") && + !hostView.includes("static BOOL NativeScriptLayoutHostedScrollViewContent") && + !hostView.includes("NativeScriptHostedContentExtent") && + !hostView.includes("scrollView.contentSize = targetSize") && + !hostView.includes( + "if ([root isKindOfClass:UIScrollView.class]) {\n return NO;\n }", + ), + "NativeScriptUIView direct-child layout may expand an undersized origin-aligned ScrollView content container without inheriting scroll offset, taking over contentSize, or shrinking long content", +); assert( hostView.includes( - "return NativeScriptChildrenViewHasVisibleChild(_childrenView, _detachedTouchSentinel);", + "return NativeScriptChildrenViewHasVisibleChild(_childrenView, _detachedTouchSentinel, self);", ), - "refreshUIKitHostView should report whether hosted React children are ready", + "refreshUIKitHostView should report whether hosted React children are ready without counting the internal carrier", ); assert( hostView.includes("UIView* touchView = _childrenView;"), "NativeScriptUIView should attach the RN touch handler to the stable detached children host", ); assert( - hostView.includes("NativeScriptViewHasGestureRecognizer(touchView, _detachedTouchHandler)") && - hostView.includes("NativeScriptGestureRecognizerAttachedView(_detachedTouchHandler)") && - hostView.includes("_detachedTouchHandlerWindow != touchView.window") && - hostView.includes("[self detachDetachedChildrenTouchHandler];"), - "NativeScriptUIView should repair a stale detached RN touch handler after UIKit window transitions", + index.includes("disableDetachedChildrenTouchHandler?: boolean") && + index.includes('"disableDetachedChildrenTouchHandler"') && + index.includes("props.disableDetachedChildrenTouchHandler === true") && + declarations.includes("disableDetachedChildrenTouchHandler?: boolean") && + read("src/NativeScriptUIViewNativeComponent.ts").includes( + "disableDetachedChildrenTouchHandler?: boolean", + ) && + read("ios/NativeScriptUIView.h").includes( + "@property(nonatomic, assign) BOOL disableDetachedChildrenTouchHandler", + ) && + read("ios/NativeScriptUIViewManager.mm").includes( + "RCT_EXPORT_VIEW_PROPERTY(disableDetachedChildrenTouchHandler, BOOL)", + ) && + read("ios/Fabric/NativeScriptUIViewComponentView.mm").includes( + "_containerView.disableDetachedChildrenTouchHandler =", + ) && + hostView.includes( + "if (_disableDetachedChildrenTouchHandler || _mountChildrenDirectlyToChildrenView)", + ) && + hostView.includes( + "[self detachDetachedChildrenTouchHandler];\n return;", + ), + "NativeScriptUIView should expose a generic host option for RN children that already live under an upstream surface touch handler", +); +assert( + hostView.includes( + "NativeScriptViewHasGestureRecognizer(touchView, _detachedTouchHandler)", + ) && + hostView.includes( + "NativeScriptGestureRecognizerAttachedView(_detachedTouchHandler)", + ) && + hostView.includes("_detachedTouchHandler != nil && _detachedTouchHandlerView == touchView") && + hostView.includes("attachedTouchHandlerView == nil") && + hostView.includes("[_detachedTouchHandler attachToView:touchView];") && + hostView.includes("reattached detached handler") && + hostView.includes("preserve hidden/window") && + hostView.includes("_detachedTouchHandlerWindow = touchView.window;") && + !hostView.includes("_detachedTouchHandlerWindow != touchView.window"), + "NativeScriptUIView should preserve and reattach a same-view detached RN touch handler across transient UIKit window transitions", +); +assert( + hostView.includes("static BOOL NativeScriptGestureRecognizerHasActiveTouches") && + hostView.includes("gesture.state == UIGestureRecognizerStateBegan") && + hostView.includes("gesture.state == UIGestureRecognizerStateChanged") && + hostView.includes("gesture.numberOfTouches > 0") && + hostView.includes("preserve active handler"), + "NativeScriptUIView should not detach or move an active RN surface touch handler during a host refresh", ); assert( hostView.includes("_detachedTouchHandlerWindow = touchView.window;") && @@ -87,21 +374,294 @@ assert( "NativeScriptUIView should keep the hosted RN touch surface interactive after refreshes", ); assert( - hostView.includes("NativeScriptFindAncestorSurfaceTouchHandler") && - hostView.includes("NativeScriptFindAncestorSurfaceTouchHandler(touchView) != nil") && - hostView.includes("[self detachDetachedChildrenTouchHandler];"), - "NativeScriptUIView should not install a duplicate detached touch handler below an ancestor RCTSurfaceTouchHandler", + hostView.includes("@implementation NativeScriptDetachedChildrenTouchSentinel") && + hostView.includes("- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent*)event {\n return NO;\n}") && + hostView.includes("- (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent*)event {\n return nil;\n}"), + "NativeScript detached-children sentinel should never become a touch target even if UIKit refresh code changes hidden or interaction flags", +); +assert( + hostView.includes("_detachedTouchSentinel.owner = nil;\n [_detachedTouchSentinel removeFromSuperview];") && + hostView.match(/_detachedTouchSentinel\.owner = nil;\n \[_detachedTouchSentinel removeFromSuperview\];/g) + ?.length >= 2, + "NativeScript detached-children sentinel should clear its owner before removal so UIKit lifecycle callbacks cannot re-enter a tearing-down host", +); +assert( + hostView.includes("static BOOL NativeScriptViewIsHostHitTestPlumbing(UIView* view)") && + hostView.includes("[view isKindOfClass:UIControl.class]") && + hostView.includes("static BOOL NativeScriptViewHasOnlySurfaceTouchHandlers(UIView* view)") && + hostView.includes("[recognizer isKindOfClass:RCTSurfaceTouchHandler.class]") && + hostView.includes('[className isEqualToString:@"NativeScriptUIView"]') && + hostView.includes('[className isEqualToString:@"NativeScriptUIViewComponentView"]') && + hostView.includes('[className isEqualToString:@"UIView"] &&') && + hostView.includes("(view.gestureRecognizers.count == 0 || NativeScriptViewHasOnlySurfaceTouchHandlers(view))") && + hostView.includes("view.subviews.count > 0") && + hostView.includes("view.gestureRecognizers.count == 0 || NativeScriptViewHasOnlySurfaceTouchHandlers(view)"), + "NativeScriptUIView should classify inert host wrappers and plain RN surface-handler carriers as touch-transparent plumbing", +); +assert( + hostView.includes("if (hitView != nil && hitView != self)") && + hostView.includes("hitViewIsTransparentHostWrapper") && + hostView.includes("hitViewIsHostPlumbing") && + hostView.includes("[static_cast(hitView) shouldHideEmptyFabricHostWrapper]") && + hostView.includes("hitView = nil;") && + hostView.includes("skip super host plumbing") && + hostView.includes("skip hosted host plumbing") && + hostView.includes( + "([self shouldHideEmptyFabricHostWrapper] || NativeScriptViewIsHostHitTestPlumbing(self))", + ) && + hostView.includes( + "if (_externalDetachedChildrenOwner) {\n return hitView;\n }\n\n UIView* hostedViews[] = { _nativeView, _childrenView };", + ) && + hostView.includes( + "return hitView;\n}\n\n- (BOOL)hostedViewIsDetachedFromHostWrapper:", + ), + "NativeScriptUIView should not swallow detached hosted React touches by returning the empty host wrapper before checking hosted content", +); +assert( + hostView.includes("- (NSArray*)accessibilityElements") && + hostView.includes("static BOOL NativeScriptViewHasHiddenUIKitAncestor(UIView* view)") && + hostView.includes("NativeScriptViewHasHiddenUIKitAncestor(self)") && + hostView.includes("[self hostedViewIsDetachedFromHostWrapper:_nativeView]") && + hostView.includes("return [super accessibilityElements];") && + !hostView.includes("[elements addObject:hostedView]") && + hostView.includes("- (NSInteger)accessibilityElementCount") && + hostView.includes("- (id)accessibilityElementAtIndex:(NSInteger)index") && + hostView.includes("- (NSInteger)indexOfAccessibilityElement:(id)element"), + "NativeScriptUIView should route detached hosted touches without duplicating the real UIKit accessibility owner chain", +); +assert( + hostView.includes("[surfaceTouchHandler attachToView:touchView];") && + hostView.includes("[self updateDetachedChildrenTouchHandlerOrigin];") && + hostView.includes( + "NativeScriptViewHasSurfaceTouchHandler(touchView, _detachedTouchHandler)", + ) && + hostView.includes( + "NativeScriptViewHasSurfaceTouchHandlerInAncestorChain(touchView, _detachedTouchHandler)", + ) && + hostView.includes( + "NativeScriptUpdateSurfaceTouchHandlerOriginsInAncestorChain(touchView, _detachedTouchHandler)", + ) && + hostView.includes( + "NativeScriptUpdateSurfaceTouchHandlerOrigins(touchView, _detachedTouchHandler)", + ) && + hostView.includes( + "((RCTSurfaceTouchHandler*)recognizer).viewOriginOffset = origin;", + ) && + hostView.includes("touchView.window == nil") && + !hostView.includes("NativeScriptWindowSurfaceTouchHandler") && + !hostView.includes("NativeScriptEnsureWindowSurfaceTouchHandler") && + !hostView.includes("NativeScriptViewHasOwnedReactHostAncestor") && + !hostView.includes("NativeScriptWindowSurfaceTouchHandlerKey") && + !hostView.includes("NativeScriptDetachedSurfaceTouchHandler") && + !hostView.includes("NS_TOUCH_DIAG"), + "NativeScriptUIView should attach RN touch handling only to windowed NativeScript-hosted React subtrees that do not already own a surface handler", +); +assert( + hostView.includes("if (!CGRectEqualToRect(subview.frame, bounds))") && + hostView.includes("if (didMutateSubview)") && + !hostView.includes("[subview layoutIfNeeded];"), + "NativeScriptUIView refresh should update stale frames without forcing or invalidating clean UIKit layout during touch dispatch", +); +assert( + hostView.includes("NativeScriptDetachedChildrenDisplaySnapshotKey") && + hostView.includes("NativeScriptInvalidateHostedSubviewDisplay") && + hostView.includes("[view.layer setNeedsDisplay];") && + hostView.includes("NativeScriptFlushHostedSubviewDisplay") && + hostView.includes("[view.layer displayIfNeeded];") && + hostView.includes("[CATransaction flush];") && + hostView.includes("- (BOOL)flushDetachedChildrenDisplay") && + hostView.includes("- (void)invalidateDetachedChildrenDisplayIfNeeded") && + hostView.includes( + "[self invalidateDetachedChildrenDisplayIfNeeded];\n [self notifyHostReadyIfNeeded];", + ), + "NativeScriptUIView should invalidate hosted RN display at attach/reparent refresh boundaries and expose an explicit synchronous display flush for first native frames", +); +assert( + hostView.includes("static BOOL NativeScriptFlushOwnerDisplay") && + hostView.includes("NativeScriptFlushUIKitHostOwnersInAncestorChain") && + hostView.includes("NativeScriptFlushUIKitHostSubviews") && + hostView.includes("BOOL NativeScriptFlushUIKitHostView(NSString* viewHandle)") && + hostView.includes("BOOL NativeScriptFlushUIKitHostViewOwner(NSString* viewHandle)") && + hostView.includes("NativeScriptUIView* owner = NativeScriptUIKitHostOwnerForView(view);") && + hostView.includes("NativeScriptFlushOwnerDisplay(owner)") && + hostView.includes("BOOL flushed = NativeScriptFlushOwnerDisplay(owner);") && + hostView.includes( + "flushed = NativeScriptFlushUIKitHostSubviews(view, 0) || flushed;", + ) && + hostView.includes( + "const BOOL flushed = NativeScriptFlushUIKitHostOwnersInAncestorChain(view);", + ) && + !hostView.includes( + "const BOOL flushed = NativeScriptFlushUIKitHostOwnersInAncestorChain(view) ||\n NativeScriptFlushUIKitHostSubviews(view, 0);", + ) && + !hostView.includes( + "BOOL NativeScriptFlushUIKitHostViewOwner(NSString* viewHandle) {\n if (![NSThread isMainThread]) {\n return NO;\n }\n\n UIView* view = NativeScriptUIViewFromHandle(viewHandle);\n if (view == nil) {\n return NO;\n }\n\n const BOOL flushed = NativeScriptFlushUIKitHostOwnersInAncestorChain(view) ||", + ), + "default display flush should refresh the direct host/tree and owner-only display flush should avoid recursively scanning every hosted React descendant", +); +{ + const nativeHitTestStart = hostView.indexOf( + "- (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent*)event", + ); + const nativeHitTestEnd = hostView.indexOf( + "- (void)didMoveToWindow", + nativeHitTestStart, + ); + assert( + nativeHitTestStart >= 0 && + nativeHitTestEnd > nativeHitTestStart && + !hostView + .slice(nativeHitTestStart, nativeHitTestEnd) + .includes("invalidateDetachedChildrenDisplay"), + "NativeScriptUIView should not invalidate hosted RN display during hit testing", + ); +} +assert( + index.includes("preserveDetachedChildrenLayout?: boolean") && + index.includes('"preserveDetachedChildrenLayout"') && + index.includes("props.preserveDetachedChildrenLayout === true") && + declarations.includes("preserveDetachedChildrenLayout?: boolean") && + read("src/NativeScriptUIViewNativeComponent.ts").includes( + "preserveDetachedChildrenLayout?: boolean", + ) && + read("ios/NativeScriptUIView.h").includes( + "@property(nonatomic, assign) BOOL preserveDetachedChildrenLayout", + ) && + read("ios/Fabric/NativeScriptUIViewComponentView.mm").includes( + "_containerView.preserveDetachedChildrenLayout = newPreserveDetachedChildrenLayout;", + ) && + hostView.includes("if (_preserveDetachedChildrenLayout)") && + hostView.includes("continue;"), + "NativeScriptUIView should expose a generic mode that preserves Fabric child layout for config hosts", +); +assert( + index.includes("collectChildren?: boolean") && + index.includes('"collectChildren"') && + index.includes("props.collectChildren === true") && + index.includes("export function collectedUIKitHostChildren") && + index.includes("export function uikitHostHandlesForView") && + !index.includes("uikitHostOwnerHandlesForView") && + index.includes("__nativeScriptCollectedUIKitHostChildren") && + index.includes("__nativeScriptUIKitHostHandlesForView") && + index.includes("componentViewHandle,") && + index.includes("containerViewHandle,") && + declarations.includes("collectChildren?: boolean") && + declarations.includes("componentViewHandle?: string") && + declarations.includes("containerViewHandle?: string") && + declarations.includes("collectedUIKitHostChildren") && + declarations.includes("uikitHostHandlesForView") && + read("src/NativeScriptUIViewNativeComponent.ts").includes( + "collectChildren?: boolean", + ) && + read("ios/NativeScriptUIView.h").includes( + "@property(nonatomic, assign) BOOL collectChildren", + ) && + read("ios/Fabric/NativeScriptUIViewComponentView.mm").includes( + "_containerView.collectChildren = newCollectChildren;", + ) && + hostView.includes("- (NSArray*)collectedChildComponentViews") && + hostView.includes( + '@"componentViewHandle" : NativeScriptHandleFromNSObject(self.superview)', + ) && + hostView.includes( + '@"containerViewHandle" : NativeScriptHandleFromNSObject(self)', + ) && + hostView.includes("NativeScriptCollectedUIKitHostChildren") && + hostView.includes("NativeScriptUIKitHostHandlesForView") && + hostView.includes("NativeScriptUIKitHostOwnerHandlesForView") && + hostView.includes("parentOwner != nil && parentOwner != owner") && + hostView.includes("current = current.superview;") && + read("ios/NativeScriptNativeApiModule.mm").includes( + "__nativeScriptUIKitHostHandlesForView", + ) && + read("ios/NativeScriptNativeApiModule.mm").includes( + "__nativeScriptUIKitHostOwnerHandlesForView", + ) && + hostView.includes("unmountCollectedChildComponentView"), + "NativeScriptUIView should expose a generic Fabric child collector for component views that own React subviews without mounting them", +); +assert( + hostView.includes("NativeScriptInstallFabricReparentingGuard") && + hostView.includes("NativeScriptRecordFabricParentBeforeMove") && + hostView.includes("NativeScriptRestoreFabricChildrenForUnmount") && + hostView.includes('NSClassFromString(@"RCTViewComponentView")') && + hostView.includes( + 'NSSelectorFromString(@"unmountChildComponentView:index:")', + ) && + hostView.includes("NativeScriptFabricGuardRCTViewComponentViewUnmountChild") && + read("ios/NativeScriptUIView.h").includes( + "- (void)restoreFabricChildComponentViewsForUnmount:(UIView*)view index:(NSInteger)index", + ) && + read("ios/Fabric/NativeScriptUIViewComponentView.mm").includes( + "[_containerView restoreFabricChildComponentViewsForUnmount:childComponentView index:index]", + ) && + read("ios/Fabric/NativeScriptUIViewComponentView.mm").includes( + "[_containerView restoreFabricChildComponentViewsForUnmount:nil index:NSNotFound]", + ), + "NativeScriptUIView should restore UIKit-reparented Fabric children before React Native Fabric unmount assertions run", +); +assert( + hostView.includes( + "NativeScriptFabricRestoreWouldCrossActiveControllerTransition", + ) && + hostView.includes("NativeScriptFabricControllerIsTransitioning") && + hostView.includes("NativeScriptFabricUnmountRelocatedChildInRuntime") && + hostView.includes( + "if (NativeScriptRestoreFabricChildrenForUnmount(expectedSuperview, child, index, nil, nil))", + ) && + hostView.includes( + "NativeScriptOriginalUIViewRemoveFromSuperview(child, @selector(removeFromSuperview));", + ) && + hostView.includes("return;"), + "NativeScriptUIView should not force Fabric children back through an active UIKit controller transition during unmount", +); +assert( + !hostView.includes("NativeScriptFindAncestorSurfaceTouchHandler") && + !hostView.includes( + "NativeScriptDetachNestedDetachedChildrenTouchHandlers", + ) && + !hostView.includes("hasActiveDetachedChildrenTouchHandler") && + !hostView.includes("NativeScriptViewHasVisibleNestedUIKitHost") && + hostView.includes( + "if (touchView.hidden || touchView.alpha <= 0.01 || touchView.window == nil)", + ) && + hostView.includes( + "NativeScriptViewHasSurfaceTouchHandlerInAncestorChain(touchView, _detachedTouchHandler)", + ) && + !hostView.includes("nativeViewHasVisibleNestedUIKitHost") && + hostView.includes("shouldUseNativeControllerTouchSurface") && + hostView.includes("touchView = _nativeView") && + hostView.includes( + "NativeScriptHostedViewContainsControllerView(_nativeView, _viewController)", + ) && + !hostView.includes( + "NativeScriptHostedViewContainsControllerView(_nativeView, _viewController) &&\n !NativeScriptViewHasVisibleNestedUIKitHost", + ) && + !hostView.includes( + "(_childrenView.hidden || !childrenViewHasVisibleChild)", + ) && + hostView.includes("[surfaceTouchHandler attachToView:touchView];"), + "NativeScriptUIView should route UIKit-reparented React islands through their own RN touch handler unless an ancestor surface already owns touches", ); assert( hostView.includes("UIView* detachView =") && - hostView.includes("NativeScriptGestureRecognizerAttachedView(_detachedTouchHandler)") && - hostView.includes("NativeScriptViewHasGestureRecognizer(detachView, _detachedTouchHandler)") && + hostView.includes( + "NativeScriptGestureRecognizerAttachedView(_detachedTouchHandler)", + ) && + hostView.includes( + "NativeScriptViewHasGestureRecognizer(detachView, _detachedTouchHandler)", + ) && hostView.includes("[_detachedTouchHandler detachFromView:detachView];"), "NativeScriptUIView should detach RCTSurfaceTouchHandler from its actual attached view, not a stale stored host view", ); assert( - hostView.includes("- (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent*)event {\n [self refreshDetachedChildrenHost];"), - "NativeScriptUIView should refresh the detached RN touch host before first hit testing", + hostView.includes( + "- (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent*)event {\n return [self hostedContentHitTest:point withEvent:event];\n}", + ) && + !hostView.includes( + "NativeScriptEnsureWindowSurfaceTouchHandler(self.window);", + ), + "NativeScriptUIView should keep hit testing on the routing path without repairing detached RN touch hosts", ); assert( !hostView.includes("NativeScriptFirstReactTaggedSubview"), @@ -110,12 +670,89 @@ assert( const fabricHostView = read("ios/Fabric/NativeScriptUIViewComponentView.mm"); assert( - fabricHostView.includes("[_containerView refreshDetachedChildrenHost];"), - "Fabric wrapper should refresh the detached RN touch host before first hit testing", + fabricHostView.includes("static BOOL NativeScriptFabricViewIsHostHitTestPlumbing(UIView* view)") && + fabricHostView.includes('[className isEqualToString:@"NativeScriptUIViewComponentView"]') && + fabricHostView.includes('[className isEqualToString:@"UIView"] &&') && + fabricHostView.includes("(view.gestureRecognizers.count == 0 || hasOnlySurfaceTouchHandlers)") && + fabricHostView.includes("view.subviews.count > 0") && + fabricHostView.includes("[recognizer isKindOfClass:RCTSurfaceTouchHandler.class]") && + fabricHostView.includes("NativeScriptFabricViewIsHostHitTestPlumbing(self)"), + "Fabric NativeScriptUIView host should not become the terminal touch target for hosted RN children", ); assert( - fabricHostView.includes("- (void)didMoveToWindow") && + fabricHostView.includes("NativeScriptFabricColorIsEffectivelyClear") && + fabricHostView.includes("NativeScriptFabricCGColorIsEffectivelyClear") && + fabricHostView.includes("- (void)refreshEmptyHostWrapperVisualState") && + fabricHostView.includes("[_containerView shouldHideEmptyFabricHostWrapper]") && + fabricHostView.includes("_emptyHostWrapperSavedLayerBackgroundColor") && + fabricHostView.includes("_emptyHostWrapperSavedContainerLayerBackgroundColor") && + fabricHostView.includes("CGFloat _emptyHostWrapperSavedAlpha;") && + fabricHostView.includes("CGFloat _emptyHostWrapperSavedContainerAlpha;") && + fabricHostView.includes("_emptyHostWrapperSavedAlpha = self.alpha;") && + fabricHostView.includes( + "_emptyHostWrapperSavedContainerAlpha = _containerView.alpha;", + ) && + fabricHostView.includes("CGColorRetain(self.layer.backgroundColor)") && + fabricHostView.includes("CGColorRetain(_containerView.layer.backgroundColor)") && + fabricHostView.includes("CGColorRelease(_emptyHostWrapperSavedLayerBackgroundColor)") && + fabricHostView.includes("CGColorRelease(_emptyHostWrapperSavedContainerLayerBackgroundColor)") && + fabricHostView.includes("self.alpha = _emptyHostWrapperSavedAlpha;") && + fabricHostView.includes( + "_containerView.alpha = _emptyHostWrapperSavedContainerAlpha;", + ) && + fabricHostView.includes("self.backgroundColor = UIColor.clearColor;") && + fabricHostView.includes("_containerView.backgroundColor = UIColor.clearColor;") && + fabricHostView.includes("self.alpha = 0;") && + fabricHostView.includes("_containerView.alpha = 0;") && + fabricHostView.includes("self.layer.backgroundColor = UIColor.clearColor.CGColor;") && + fabricHostView.includes("_containerView.layer.backgroundColor = UIColor.clearColor.CGColor;") && + fabricHostView.includes("self.opaque = NO;") && + fabricHostView.includes("_containerView.opaque = NO;") && + fabricHostView.includes("self.layer.opaque = NO;") && + fabricHostView.includes("_containerView.layer.opaque = NO;") && + fabricHostView.includes("[self.layer setNeedsDisplay];") && + fabricHostView.includes("[_containerView.layer setNeedsDisplay];") && + fabricHostView.includes("restoreEmptyHostWrapperVisualStateIfNeeded") && + fabricHostView.includes("[self refreshEmptyHostWrapperVisualState];") && + fabricHostView.includes("[self restoreEmptyHostWrapperVisualStateIfNeeded];"), + "Fabric NativeScriptUIView host should make empty detached host wrappers paint-transparent, not only hit-test-transparent", +); +assert( + hostView.includes( + "- (BOOL)shouldHideEmptyFabricHostWrapper {\n UIView* componentView = self.superview;\n if (componentView != nil && (_childrenView == componentView || _nativeView == componentView)) {\n return NO;\n }\n\n if ([self hasVisibleSubviewMountedInHostWrapper]) {\n return NO;\n }", + ) && + hostView.includes( + "const BOOL hasExternalDetachedChildrenOwner =\n _externalDetachedChildrenOwner && (_nativeView != nil || _childrenView != nil);", + ) && + hostView.includes( + "return hasDetachedHostedContent || hasExternalDetachedChildrenOwner;", + ), + "NativeScriptUIView should make empty external-detached-owner shells paint-inert while keeping hosts with visible mounted children visible", +); +assert( + fabricHostView.includes("- (void)refreshContainerViewFrameAndHost") && + fabricHostView.includes("- (void)refreshContainerViewFrameIfNeeded") && + fabricHostView.includes( + "if (!CGRectEqualToRect(_containerView.frame, self.bounds))", + ) && + fabricHostView.includes("_containerView.frame = self.bounds;") && + fabricHostView.includes("[_containerView setNeedsLayout];") && + !fabricHostView.includes("[_containerView layoutIfNeeded];") && fabricHostView.includes("[_containerView refreshDetachedChildrenHost];"), + "Fabric wrapper should correct stale host frames without forcing UIKit layout during Fabric commits", +); +assert( + fabricHostView.includes( + "- (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent*)event {\n [self refreshContainerViewFrameIfNeeded];", + ) && + !fabricHostView.includes( + "- (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent*)event {\n [self refreshContainerViewFrameAndHost];", + ), + "Fabric wrapper should keep hit testing on a frame-only path instead of repairing detached RN touch hosts", +); +assert( + fabricHostView.includes("- (void)didMoveToWindow") && + fabricHostView.includes("[self refreshContainerViewFrameAndHost];"), "Fabric wrapper should refresh detached RN touch hosts when UIKit moves the wrapper between windows", ); assert( @@ -132,11 +769,40 @@ assert( ), "Fabric layout updates should refresh the touch host origin and handler, not just resize children", ); +assert( + fabricHostView.includes("+ (BOOL)shouldBeRecycled") && + fabricHostView.includes("return NO;"), + "Fabric NativeScriptUIView hosts arbitrary UIKit/RN native state and should opt out of Fabric component recycling like upstream screens component views", +); +assert( + hostView.includes("return enabled != nullptr && enabled[0] == '1';"), + "NativeScript touch debug logging should require NS_NS_TOUCH_DEBUG=1 so explicit off values do not log on the touch hot path", +); const moduleSource = read("ios/NativeScriptNativeApiModule.mm"); assert( moduleSource.includes("__nativeScriptRefreshUIKitHostView"), "worklet runtime install should expose the refresh host function", ); +assert( + moduleSource.includes("__nativeScriptFlushUIKitHostView") && + moduleSource.includes("__nativeScriptFlushUIKitHostViewOwner") && + moduleSource.includes("NativeScriptFlushUIKitHostView(nativeHandle)") && + moduleSource.includes("NativeScriptFlushUIKitHostViewOwner(nativeHandle)"), + "worklet runtime install should expose the display flush host functions", +); +assert( + moduleSource.includes("__nativeScriptAttachViewControllerToNearestParent") && + moduleSource.includes("NativeScriptAttachViewControllerToNearestParent(") && + moduleSource.includes("BOOL allowRootParent") && + moduleSource.includes("controllerHandle, viewHandle, allowRootParent"), + "worklet runtime install should expose the generic nearest-parent attachment host function", +); +assert( + moduleSource.includes("__nativeScriptNearestViewControllerForView") && + moduleSource.includes("NativeScriptNearestViewControllerForView(viewHandle)") && + moduleSource.includes("return jsi::Value::null();"), + "worklet runtime install should expose the nil-safe nearest UIViewController lookup host function", +); console.log("uikit host refresh API tests passed"); diff --git a/packages/react-native/test/uikit-host-transaction-api.test.js b/packages/react-native/test/uikit-host-transaction-api.test.js new file mode 100644 index 000000000..d7bc5b752 --- /dev/null +++ b/packages/react-native/test/uikit-host-transaction-api.test.js @@ -0,0 +1,246 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const packageRoot = path.resolve(__dirname, ".."); + +function read(relativePath) { + return fs.readFileSync(path.join(packageRoot, relativePath), "utf8"); +} + +const index = read("src/index.ts"); +const declarations = read("src/index.ts"); +const hostViewHeader = read("ios/NativeScriptUIView.h"); +const hostView = read("ios/NativeScriptUIView.mm"); +const manager = read("ios/NativeScriptUIViewManager.mm"); +const fabricView = read("ios/Fabric/NativeScriptUIViewComponentView.mm"); +const nativeComponent = read("src/NativeScriptUIViewNativeComponent.ts"); +const transactionParser = index.slice( + index.indexOf("function parseUIKitFabricTransactionJson"), + index.indexOf("function parseUIKitFabricMountedChildRecord"), +); +const normalizedIndex = index.replace(/\s+/g, " "); + +assert( + declarations.includes("transactionCommitted?: (") && + declarations.includes( + "readonly fabricTransaction: UIKitFabricTransaction", + ) && + declarations.includes( + "readonly children: readonly UIKitFabricMountedChild[]", + ) && + declarations.includes("readonly mutations: readonly UIKitFabricMutation[]") && + declarations.includes("export type UIKitFabricMutation") && + declarations.includes("readonly hasModifiedProps: boolean") && + index.includes( + "const transactionCommittedHost = definition.transactionCommitted", + ) && + index.includes("function parseUIKitFabricTransactionJson") && + index.includes('phase === "transactionCommitted"') && + index.includes('phase === "refresh"') && + index.includes("host.context.setFabricTransaction(") && + index.includes("parseUIKitFabricTransactionJson(transactionJson)") && + index.includes("hasModifiedProps:") && + index.includes("parseUIKitFabricMountedChildRecord") && + normalizedIndex.includes( + "commitUIKitHostFabricTransaction( host, nextProps, host.previousProps, parseUIKitFabricTransactionJson(transactionJson), );", + ) && + index.includes("host.transactionCommitted?.(props, previousProps);") && + index.includes("transactionCommittedHost?.("), + "defineUIKitHost should expose a UI-worklet Fabric transaction committed lifecycle with child and prop metadata", +); + +assert( + transactionParser.includes("const children: UIKitFabricMountedChild[] = [];") && + transactionParser.includes( + 'const ownerComponentViewHandle = stringRecordValue(', + ) && + transactionParser.includes("ownerContainerViewHandle") && + transactionParser.includes("const mutations: UIKitFabricMutation[] = [];") && + transactionParser.includes('type: stringRecordValue(event, "type")') && + transactionParser.includes("parentTag: numberOrNull(event.parentTag)") && + transactionParser.includes("children.push({") && + transactionParser.includes("ownerComponentViewHandle,") && + !transactionParser.includes(".map("), + "Fabric transaction child parsing should avoid Array.map callbacks so UI worklets do not lose child-record parser bindings", +); + +assert( + hostViewHeader.includes("- (void)notifyFabricTransactionCommitted") && + hostViewHeader.includes("modifiedProps:(BOOL)hasModifiedProps") && + hostViewHeader.includes( + "- (NSArray*>*)fabricMountedChildrenSnapshot", + ) && + hostViewHeader.includes( + "@property(nonatomic, assign) BOOL immediateTransactionCommit", + ) && + hostView.includes("- (void)notifyFabricTransactionCommitted") && + hostView.includes('"children" : [self fabricMountedChildrenSnapshot]') && + hostView.includes('"mutations" : mutations ?: @[]') && + hostView.includes("- (NSString*)fabricTransactionJsonWithModifiedChildren:") && + hostView.includes( + '[self runUIKitHostLifecycle:@"refresh"\n transactionJson:[self fabricTransactionJsonWithModifiedChildren:YES', + ) && + hostView.includes("hasModifiedProps") && + hostView.includes("NativeScriptRunUIKitHostLifecycleWithInfo") && + hostView.includes("replayFabricTransactionAfterHostCreationIfNeeded") && + hostView.includes("_hasReplayedFabricTransactionAfterHostCreation") && + hostView.includes("NativeScriptViewIsDescendantOfView(self, _childrenView)") && + hostView.includes( + '[self runUIKitHostLifecycle:@"transactionCommitted" transactionJson:transactionJson];', + ), + "NativeScriptUIView should forward Fabric transaction commits and mutation metadata into the UI-worklet host lifecycle, including children mounted before host creation", +); + +assert( + // The out-of-band props-revision commit reuses the shared, monotonic Fabric + // delivery token (_fabricTransactionDeliveryToken / advanceFabricTransaction- + // DeliveryToken) rather than a dedicated props-commit counter; the coalescing + // behavior this pins -- capture token, dispatch_async, drop if superseded -- + // is unchanged. + hostView.includes("NSUInteger _fabricTransactionDeliveryToken;") && + hostView.includes( + "- (void)scheduleUIKitHostPropsTransactionCommitIfNeeded", + ) && + hostView.includes( + "if (_hostId.length == 0 || _updateRevision <= 0)", + ) && + hostView.includes( + "const NSUInteger transactionToken = [self advanceFabricTransactionDeliveryToken];", + ) && + hostView.includes("dispatch_async(dispatch_get_main_queue(), ^{") && + hostView.includes( + "self->_fabricTransactionDeliveryToken != transactionToken", + ) && + hostView.includes( + "notifyFabricTransactionCommittedWithModifiedChildren:NO modifiedProps:YES", + ) && + hostView.includes( + '[self runUIKitHostLifecycle:@"update"];\n [self scheduleUIKitHostPropsTransactionCommitIfNeeded];', + ) && + hostView.includes("return ++_fabricTransactionDeliveryToken;"), + "NativeScriptUIView should coalesce a generic transactionCommitted callback after UIKit host prop revision updates so controller and native-view hosts can react to committed prop mutations without waiting for child mounts", +); + +assert( + index.includes("function commitUIKitHostFabricTransaction(") && + index.includes("host.context.setFabricTransaction(transaction);") && + index.includes("host.mountingTransactionDidMount?.(props, previousProps);") && + index.includes("host.transactionCommitted?.(props, previousProps);") && + index.includes("hasModifiedProps: false") && + normalizedIndex.includes( + "commitUIKitHostFabricTransaction( host, nextProps, host.previousProps, parseUIKitFabricTransactionJson(transactionJson), );", + ) && + normalizedIndex.includes( + "const updatePreviousProps = host.previousProps ?? fallbackPreviousProps;", + ) && + normalizedIndex.includes( + "commitUIKitHostFabricTransaction( host, nextProps, updatePreviousProps, { children: [], hasModifiedChildren: false, hasModifiedProps: true, mutations: [], }, );", + ), + "defineUIKitHost should publish a generic prop-mutation Fabric transaction after direct UI-thread React prop updates, matching the native transactionCommitted lifecycle path for mount-through-native hosts", +); + +assert( + nativeComponent.includes("immediateTransactionCommit?: boolean") && + declarations.includes("immediateTransactionCommit?: boolean") && + index.includes('"immediateTransactionCommit"') && + index.includes( + "props.immediateTransactionCommit === true ? true : undefined", + ) && + manager.includes( + "RCT_EXPORT_VIEW_PROPERTY(immediateTransactionCommit, BOOL)", + ), + "defineUIKitHost should expose an opt-in immediate transaction commit host prop", +); + +assert( + fabricView.includes("#import ") && + fabricView.includes("RCTMountingTransactionObserving") && + fabricView.includes("- (void)mountingTransactionDidMount:") && + fabricView.includes("NativeScriptFabricMutationRecords(transaction)") && + fabricView.includes("NativeScriptFabricMutationRecord(") && + fabricView.includes('@"parentTag" : @(mutation.parentTag)') && + fabricView.includes('@"newChildTag" : @(newView.tag)') && + fabricView.includes('@"oldChildTag" : @(oldView.tag)') && + fabricView.includes("if (!hasModifiedChildren && !hasModifiedProps)") && + fabricView.includes( + "if (_containerView.immediateTransactionCommit && !transactionHasRemovalMutation)", + ) && + fabricView.includes( + "notifyFabricTransactionCommittedWithModifiedChildren:hasModifiedChildren", + ) && + fabricView.includes("modifiedProps:hasModifiedProps") && + fabricView.includes("mutations:mutationRecords") && + fabricView.includes("dispatch_async(dispatch_get_main_queue(), ^{") && + !fabricView.includes( + "notifyFabricTransactionCommittedWithModifiedChildren:self->_hasModifiedChildrenInCurrentTransaction", + ), + "NativeScriptUIViewComponentView should notify UIKit hosts for Fabric transactions that changed direct children or host props", +); + +assert( + fabricView.includes("_hasModifiedChildrenInCurrentTransaction = YES;") && + fabricView.includes("_hasModifiedPropsInCurrentTransaction = YES;") && + fabricView.includes("_hasObservedPropsUpdateSinceLastTransaction = YES;") && + fabricView.includes( + "if (!_hasObservedPropsUpdateSinceLastTransaction) {\n _hasModifiedPropsInCurrentTransaction = NO;\n }", + ) && + fabricView.includes("_hasObservedPropsUpdateSinceLastTransaction = NO;") && + fabricView.includes("_hasModifiedChildrenInCurrentTransaction = NO;") && + fabricView.includes("_hasModifiedPropsInCurrentTransaction = NO;") && + fabricView.includes("_mountingTransactionToken"), + "Fabric transaction observer should keep explicit child and prop mutation markers available for host parity code, even when Fabric reports prop changes before mountingTransactionWillMount", +); + +assert( + // The fallback reuses the container view's shared, monotonic delivery token + // (advanceFabricTransactionDeliveryToken / fabricTransactionDeliveryToken) + // instead of a dedicated fallback counter; on delivery it clears the + // coalesced pending flags. The coalescing behavior this pins is unchanged. + fabricView.includes( + "self->_hasPendingFabricTransactionCommitFallbackChildren = NO;", + ) && + fabricView.includes( + "BOOL _hasPendingFabricTransactionCommitFallbackChildren;", + ) && + fabricView.includes( + "BOOL _hasPendingFabricTransactionCommitFallbackProps;", + ) && + fabricView.includes( + "- (void)scheduleFabricTransactionCommitFallbackIfNeeded", + ) && + fabricView.includes( + "_hasPendingFabricTransactionCommitFallbackChildren =\n _hasPendingFabricTransactionCommitFallbackChildren || hasModifiedChildren;", + ) && + fabricView.includes( + "_hasPendingFabricTransactionCommitFallbackProps =\n _hasPendingFabricTransactionCommitFallbackProps || hasModifiedProps;", + ) && + fabricView.includes( + "const NSUInteger fallbackToken = [_containerView advanceFabricTransactionDeliveryToken];", + ) && + fabricView.includes( + "[self->_containerView fabricTransactionDeliveryToken] != fallbackToken", + ) && + fabricView.includes( + "const BOOL hasModifiedChildren =\n self->_hasModifiedChildrenInCurrentTransaction ||\n self->_hasPendingFabricTransactionCommitFallbackChildren;", + ) && + fabricView.includes( + "const BOOL hasModifiedProps =\n self->_hasModifiedPropsInCurrentTransaction ||\n self->_hasPendingFabricTransactionCommitFallbackProps;", + ) && + fabricView.includes( + "_hasModifiedChildrenInCurrentTransaction ||\n _hasPendingFabricTransactionCommitFallbackChildren;", + ) && + fabricView.includes( + "_hasModifiedPropsInCurrentTransaction ||\n _hasPendingFabricTransactionCommitFallbackProps;", + ) && + fabricView.includes( + "notifyFabricTransactionCommittedWithModifiedChildren:hasModifiedChildren", + ) && + fabricView.includes("[self scheduleFabricTransactionCommitFallbackIfNeeded];") && + fabricView.includes( + "self->_hasPendingFabricTransactionCommitFallbackProps = NO;", + ), + "NativeScriptUIViewComponentView should coalesce a generic UIKit-host transaction commit fallback for child and prop mutations when Fabric does not deliver a mountingTransactionDidMount callback for the component view", +); + +console.log("uikit host transaction API tests passed"); diff --git a/packages/react-native/test/uikit-host-transaction-delivery-token-api.test.js b/packages/react-native/test/uikit-host-transaction-delivery-token-api.test.js new file mode 100644 index 000000000..fe29dcacf --- /dev/null +++ b/packages/react-native/test/uikit-host-transaction-delivery-token-api.test.js @@ -0,0 +1,118 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const packageRoot = path.resolve(__dirname, ".."); + +function read(relativePath) { + return fs.readFileSync(path.join(packageRoot, relativePath), "utf8"); +} + +const index = read("src/index.ts"); +const declarations = read("src/index.ts"); +const hostView = read("ios/NativeScriptUIView.mm"); + +const normalizedIndex = index.replace(/\s+/g, " "); +const normalizedHostView = hostView.replace(/\s+/g, " "); + +// SEAM D STAGE 0 follow-up (runtime side of the O(1) pop-time readiness +// predicate): the runtime already maintains an integer, +// `_fabricTransactionDeliveryToken`, bumped exactly-once per ACTUAL delivery +// inside the single funnel `notifyFabricTransactionCommittedWithModifiedChildren: +// modifiedProps:mutations:`. This stage only surfaces that already-correct +// integer to JS -- it adds no new native bookkeeping. + +assert( + declarations.includes("export type UIKitFabricTransaction = {") && + declarations.includes("readonly deliveryToken?: number;"), + "src/index.d.ts UIKitFabricTransaction should declare an optional deliveryToken field", +); + +assert( + index.includes("export type UIKitFabricTransaction = {") && + index.includes("readonly deliveryToken?: number;"), + "src/index.ts UIKitFabricTransaction should declare an optional deliveryToken field matching src/index.d.ts", +); + +// The native dictionary literal built by fabricTransactionJsonWithModifiedChildren: +// modifiedProps:mutations: (the JSON assembly shared by every transaction +// lifecycle event, including the transactionCommitted funnel) must surface the +// ivar under the same "deliveryToken" key the JS parser reads. +assert( + normalizedHostView.includes( + '@"children" : [self fabricMountedChildrenSnapshot], @"hasModifiedChildren" : @(hasModifiedChildren), @"hasModifiedProps" : @(hasModifiedProps), @"mutations" : mutations ?: @[],', + ) && normalizedHostView.includes('@"deliveryToken" : @(_fabricTransactionDeliveryToken),'), + "NativeScriptUIView fabricTransactionJsonWithModifiedChildren:modifiedProps:mutations: should surface _fabricTransactionDeliveryToken as deliveryToken in the transaction JSON", +); + +// Monotonicity: _fabricTransactionDeliveryToken must never be reset or +// reassigned to anything other than an increment -- only `+= 1` (the commit +// funnel, the props-revision scheduler) or the `++` prefix form (the +// dedicated advance accessor) may touch it. This is what makes the surfaced +// value strictly increasing across deliveries for a given host instance. +const deliveryTokenMutations = hostView.match( + /_fabricTransactionDeliveryToken\s*(\+=\s*1|=\s*\+\+_fabricTransactionDeliveryToken)?[^;]*;/g, +) || []; +const nonIncrementMutations = hostView.match( + /_fabricTransactionDeliveryToken\s*=(?!=)\s*(?!\+\+_fabricTransactionDeliveryToken\b)[^;]*;/g, +) || []; +assert( + deliveryTokenMutations.length > 0 && nonIncrementMutations.length === 0, + "_fabricTransactionDeliveryToken should only ever be incremented (never reset or reassigned to a non-increment value), so the deliveryToken surfaced to JS is strictly increasing across deliveries per host", +); + +// The bump inside the transactionCommitted funnel must precede the JSON +// build call within the same method body, so the token surfaced for THIS +// delivery already reflects THIS delivery's increment (not the prior one). +const funnelStart = hostView.indexOf( + "- (void)notifyFabricTransactionCommittedWithModifiedChildren:(BOOL)hasModifiedChildren\n" + + " modifiedProps:(BOOL)hasModifiedProps\n" + + " mutations:", +); +assert(funnelStart >= 0, "expected to locate the transactionCommitted funnel method"); +const funnelEnd = hostView.indexOf("\n}\n", funnelStart); +const funnelBody = hostView.slice(funnelStart, funnelEnd); +const bumpIndex = funnelBody.indexOf("_fabricTransactionDeliveryToken += 1;"); +const jsonBuildIndex = funnelBody.indexOf("fabricTransactionJsonWithModifiedChildren:hasModifiedChildren"); +assert( + bumpIndex >= 0 && jsonBuildIndex >= 0 && bumpIndex < jsonBuildIndex, + "the transactionCommitted funnel should bump _fabricTransactionDeliveryToken before building the transaction JSON that surfaces it, so JS observes the freshly-incremented value for this exact delivery", +); + +// parseUIKitFabricTransactionJson must extract deliveryToken (numeric, +// non-NaN/non-infinite guarded like the existing mutation numeric fields) +// from a real native payload... +const transactionParser = index.slice( + index.indexOf("function parseUIKitFabricTransactionJson"), + index.indexOf("function parseUIKitFabricMountedChildRecord"), +); +assert( + transactionParser.includes( + "(parsed as Record).deliveryToken", + ) && + transactionParser.includes('typeof deliveryTokenValue === "number"') && + transactionParser.includes("deliveryTokenValue !== Infinity") && + transactionParser.includes("deliveryTokenValue !== -Infinity") && + transactionParser.includes("deliveryToken,"), + "parseUIKitFabricTransactionJson should extract a guarded numeric deliveryToken from the native payload", +); + +// ...but the synthesized empty/fallback transaction literals (no-payload +// early return, the parse-failure catch branch, the finally-block resets, +// and the mountChild/unmountChild/mountingTransactionWillMount synthetic +// transactions) must simply OMIT the field rather than fabricate a token, +// since fabricating one could collide with or shadow a real per-host +// sequence number a Stage 2 consumer derives readiness from. +const emptyTransactionLiteralPattern = + /\{\s*children:\s*\[\],\s*hasModifiedChildren:\s*(?:false|true),\s*hasModifiedProps:\s*(?:false|true),\s*mutations:\s*\[\],\s*\}/g; +const emptyTransactionLiterals = index.match(emptyTransactionLiteralPattern) || []; +assert( + emptyTransactionLiterals.length >= 5, + "expected to find the synthesized empty-transaction literals (no-payload guard, catch branch, transaction-finished resets, mountChild/unmountChild synthetic transactions)", +); +assert( + emptyTransactionLiterals.every((literal) => !literal.includes("deliveryToken")), + "synthesized empty-transaction literals should omit deliveryToken rather than fabricate one", +); + +console.log("uikit host transaction delivery token API tests passed"); diff --git a/packages/react-native/test/uikit-tabbar-hit-test.test.js b/packages/react-native/test/uikit-tabbar-hit-test.test.js index 2e47c0379..7c3c3a673 100644 --- a/packages/react-native/test/uikit-tabbar-hit-test.test.js +++ b/packages/react-native/test/uikit-tabbar-hit-test.test.js @@ -17,6 +17,19 @@ for (const relativePath of [ source.includes("PointInsideTabBarHitArea"), `${relativePath} should gate tab bar passthrough on the tab bar hit area`, ); + assert( + source.includes("VisibleControllerTabBarAtPoint") && + source.includes("window.rootViewController") && + source.includes("root isKindOfClass:UIWindow.class"), + `${relativePath} should resolve window-level tab bar passthrough through UIKit controllers instead of scanning the full view tree`, + ); + assert( + source.includes("tabBar.userInteractionEnabled") && + source.includes("if (root == nil)") && + !source.includes("root.hidden || root.alpha <= 0.01 || !root.userInteractionEnabled") && + !source.includes("root.hidden || root.alpha <= 0.01"), + `${relativePath} should validate tab bar interaction without pruning private UIKit ancestors`, + ); assert( source.includes("EffectiveTabBarHitBounds"), `${relativePath} should cap oversized tab bar visual bounds before hit testing`, @@ -25,10 +38,84 @@ for (const relativePath of [ source.includes("CGRectInset(bounds, -24, -16)"), `${relativePath} should allow a small expanded tab bar hit target`, ); + assert( + source.includes("TabBarWindowHitFrame") && + source.includes("convertRect:tabBar.bounds toView:window") && + source.includes("TabBarWindowHitBounds") && + source.includes("CGPointMake(windowPoint.x - tabBar.frame.origin.x") && + source.includes("windowPoint.y - tabBar.frame.origin.y"), + `${relativePath} should compare tab bar hit frames in window coordinates and keep the private-container fallback`, + ); + assert( + source.includes("tabBarHitView == tabBar") && + source.includes("fallbackHitView != nil && fallbackHitView != tabBar"), + `${relativePath} should retry the private-container tab bar point when UIKit only hits the tab bar shell`, + ); + assert( + source.includes("const CGFloat topEdge = window != nil ? window.safeAreaInsets.top + 20 : 64") && + source.includes("const CGFloat maximumHeight = MAX(fittingSize.height + 32, 96)") && + source.includes("if (frame.size.height > maximumHeight)") && + source.includes("frame.origin.y = CGRectGetMaxY(frame) - maximumHeight") && + source.includes("if (CGRectGetMinY(frame) <= topEdge)") && + source.includes("frame.size.height += 16") && + source.includes("frame.size.height += 32"), + `${relativePath} should clamp stale tab bar window hit frames before expanding them over content`, + ); + assert( + source.indexOf("if (!CGRectContainsPoint(frameHitBounds, windowPoint))") > + source.indexOf("CGRect frameHitBounds = ") && + source.indexOf("if (!CGRectContainsPoint(frameHitBounds, windowPoint))") < + source.indexOf("CGPoint localPoint = [tabBar convertPoint:windowPoint fromView:window]"), + `${relativePath} should reject points outside the tab bar window frame before trusting converted tab bar coordinates`, + ); assert( !source.includes("VisibleHitViewAtPoint"), `${relativePath} should not use recursive tab bar descendants as the passthrough hit area`, ); } +const hostHeader = read("ios/NativeScriptUIView.h"); +const hostView = read("ios/NativeScriptUIView.mm"); +const fabricView = read("ios/Fabric/NativeScriptUIViewComponentView.mm"); + +assert( + hostHeader.includes("- (BOOL)hostedContentPointInside:(CGPoint)point withEvent:(UIEvent*)event"), + "NativeScriptUIView should expose a generic hosted-content pointInside helper to the Fabric wrapper", +); +assert( + hostHeader.includes("- (UIView*)hostedContentHitTest:(CGPoint)point withEvent:(UIEvent*)event"), + "NativeScriptUIView should expose a generic hosted-content hitTest helper to the Fabric wrapper", +); +assert( + hostView.includes("- (BOOL)hostedContentPointInside:(CGPoint)point withEvent:(UIEvent*)event") && + hostView.includes("[hostedView pointInside:hostedPoint withEvent:event]") && + hostView.includes("NativeScriptVisibleTabBarAtPoint") && + hostView.includes("- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent*)event"), + "NativeScriptUIView should allow hosted UIKit content to receive touches outside the wrapper bounds", +); +assert( + hostView.includes("NativeScriptHitTestTabBarAtPoint(hostedView, self.window, windowPoint, event)") && + hostView.indexOf("NativeScriptHitTestTabBarAtPoint(hostedView, self.window, windowPoint, event)") < + hostView.indexOf("UIView* hitView = [super hitTest:point withEvent:event]"), + "NativeScriptUIView should prefer owned UIKit tab bar hits before hosted RN content", +); +assert( + fabricView.includes("- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent*)event") && + fabricView.includes("[_containerView hostedContentPointInside:containerPoint withEvent:event]") && + fabricView.includes("NativeScriptFabricVisibleTabBarAtPoint"), + "NativeScriptUIViewComponentView should ask hosted content before rejecting out-of-bounds UIKit touches", +); +assert( + fabricView.includes("[_containerView hostedContentHitTest:containerPoint withEvent:event]") && + fabricView.indexOf("[_containerView hostedContentHitTest:containerPoint withEvent:event]") < + fabricView.indexOf("UIView* hitView = [super hitTest:point withEvent:event]"), + "NativeScriptUIViewComponentView should ask hosted UIKit content for high-priority hits before Fabric consumes touches", +); +assert( + fabricView.includes("NativeScriptFabricHitTestTabBarAtPoint(self.window, self.window, windowPoint, event)") && + fabricView.indexOf("NativeScriptFabricHitTestTabBarAtPoint(self.window, self.window, windowPoint, event)") < + fabricView.indexOf("[_containerView hostedContentHitTest:containerPoint withEvent:event]"), + "NativeScriptUIViewComponentView should let visible UIKit tab bars beat full-height RN screen content", +); + console.log("uikit tab bar hit-test tests passed"); diff --git a/packages/react-native/test/worklets-frame-loop.test.js b/packages/react-native/test/worklets-frame-loop.test.js index 05daecfca..40b6b049c 100644 --- a/packages/react-native/test/worklets-frame-loop.test.js +++ b/packages/react-native/test/worklets-frame-loop.test.js @@ -29,6 +29,13 @@ assert( index.includes("NSTimerClass.timerWithTimeIntervalRepeatsBlock"), "UI runtime timers should use native NSTimer instead of RAF polling", ); +assert( + index.includes('nativeApiClass("NSTimer")') && + index.includes('nativeApiClass("NSRunLoop")') && + !index.includes("globalObject.NSTimer") && + !index.includes("globalObject.NSRunLoop"), + "UI runtime timers should resolve Foundation classes lazily through the Native API host", +); assert( index.includes("NSRunLoopClass.mainRunLoop.addTimerForMode"), "native UI timers should run in common run-loop modes", diff --git a/packages/react-native/test/worklets-setup-error.test.js b/packages/react-native/test/worklets-setup-error.test.js new file mode 100644 index 000000000..f19cd7215 --- /dev/null +++ b/packages/react-native/test/worklets-setup-error.test.js @@ -0,0 +1,32 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const packageRoot = path.resolve(__dirname, ".."); +const index = fs.readFileSync(path.join(packageRoot, "src/index.ts"), "utf8"); + +assert( + index.includes("function workletsSetupError(reason: string, cause?: unknown)"), + "Worklets setup errors should accept the underlying failure as a cause", +); +assert( + index.includes("formatWorkletsSetupCause(cause)") && + index.includes('const causeMessage = formatWorkletsSetupCause(cause);'), + "Worklets setup errors should include the underlying failure message when available", +); +assert( + index.includes('typeof errorLike.message === "string"'), + "Worklets setup errors should include messages from cross-runtime error-like objects", +); +assert( + index.includes("setupError.cause = cause;"), + "Worklets setup errors should preserve the original error object on cause", +); +assert( + index.includes( + "throw workletsSetupError(\n `NativeScript.runOnUI requires ${workletsPackageName}`,\n error,\n );", + ), + "react-native-worklets require failures should not be masked by a generic setup error", +); + +console.log("worklets setup error tests passed"); diff --git a/scripts/build_react_native_turbomodule.sh b/scripts/build_react_native_turbomodule.sh index e094063f7..6d452c0a7 100755 --- a/scripts/build_react_native_turbomodule.sh +++ b/scripts/build_react_native_turbomodule.sh @@ -63,6 +63,7 @@ mkdir -p \ "$PACKAGE_DIR/native-api/ffi/objc/hermes" \ "$PACKAGE_DIR/native-api/ffi/objc/shared" \ "$PACKAGE_DIR/native-api/ffi/objc/shared/bridge" \ + "$PACKAGE_DIR/native-api/ffi/objc/shared/bridge/host_objects" \ "$PACKAGE_DIR/native-api/metadata/include" \ "$PACKAGE_DIR/metadata" \ "$PACKAGE_DIR/ios/vendor/libffi/include" \ @@ -81,6 +82,10 @@ cp NativeScript/ffi/objc/shared/bridge/HostObjects.mm "$PACKAGE_DIR/native-api/f cp NativeScript/ffi/objc/shared/bridge/Install.mm "$PACKAGE_DIR/native-api/ffi/objc/shared/bridge/" cp NativeScript/ffi/objc/shared/bridge/Invocation.mm "$PACKAGE_DIR/native-api/ffi/objc/shared/bridge/" cp NativeScript/ffi/objc/shared/bridge/TypeConv.mm "$PACKAGE_DIR/native-api/ffi/objc/shared/bridge/" +cp NativeScript/ffi/objc/shared/bridge/InteropProfiler.h "$PACKAGE_DIR/native-api/ffi/objc/shared/bridge/" +cp NativeScript/ffi/objc/shared/bridge/SelectorGroupState.h "$PACKAGE_DIR/native-api/ffi/objc/shared/bridge/" +cp NativeScript/ffi/objc/shared/bridge/SelectorGroupCall.h "$PACKAGE_DIR/native-api/ffi/objc/shared/bridge/" +cp NativeScript/ffi/objc/shared/bridge/host_objects/*.mm "$PACKAGE_DIR/native-api/ffi/objc/shared/bridge/host_objects/" cp NativeScript/ffi/objc/shared/NativeApiBackendConfig.h "$PACKAGE_DIR/native-api/ffi/objc/shared/" cp NativeScript/ffi/objc/shared/SignatureDispatchCore.h "$PACKAGE_DIR/native-api/ffi/objc/shared/" cp NativeScript/ffi/objc/shared/PreparedSignatureDispatch.h "$PACKAGE_DIR/native-api/ffi/objc/shared/" diff --git a/scripts/run-tests-ios.js b/scripts/run-tests-ios.js index b39352eaa..c38666e41 100644 --- a/scripts/run-tests-ios.js +++ b/scripts/run-tests-ios.js @@ -967,22 +967,28 @@ function collectRecentSimulatorLogs(udid, pid) { ? `processID == ${pid}` : 'process == "TestRunner"'; - const result = run("xcrun", [ - "simctl", - "spawn", - udid, - "log", - "show", - "--style", - "compact", - "--last", - simulatorLogLookback, - "--predicate", - predicate - ]); + let result; + try { + result = run("xcrun", [ + "simctl", + "spawn", + udid, + "log", + "show", + "--style", + "compact", + "--last", + simulatorLogLookback, + "--predicate", + predicate + ]); + } catch (error) { + return `WARNING: unable to collect recent simulator logs: ${error.message}`; + } if (result.status !== 0) { - return ""; + const detail = (result.stderr || result.stdout || "").trim(); + return `WARNING: unable to collect recent simulator logs (simctl exited ${result.status}${detail ? `: ${detail}` : ""}).`; } const text = result.stdout || ""; @@ -1065,11 +1071,26 @@ function readJunitFileState(udid) { }; } -function collectSimulatorProcessSnapshot(udid) { - const result = run("xcrun", ["simctl", "spawn", udid, "ps", "-axo", "pid,ppid,stat,etime,command"], { - timeout: simctlQueryTimeoutMs - }); +function collectSimulatorProcessSnapshot(udid, options = {}) { + let result; + try { + result = run("xcrun", ["simctl", "spawn", udid, "ps", "-axo", "pid,ppid,stat,etime,command"], { + timeout: simctlQueryTimeoutMs + }); + } catch (error) { + if (options.includeErrors) { + return `WARNING: unable to collect simulator process snapshot: ${error.message}`; + } + + return null; + } + if (result.status !== 0) { + if (options.includeErrors) { + const detail = (result.stderr || result.stdout || "").trim(); + return `WARNING: unable to collect simulator process snapshot (simctl exited ${result.status}${detail ? `: ${detail}` : ""}).`; + } + return null; } @@ -1125,7 +1146,7 @@ function formatInactivityDiagnostics(udid, state, pid) { } sections.push(`--- App container state ---\n${junitSummaryLines.join("\n")}`); - const processSnapshot = collectSimulatorProcessSnapshot(udid); + const processSnapshot = collectSimulatorProcessSnapshot(udid, { includeErrors: true }); if (processSnapshot) { sections.push(`--- Simulator process snapshot ---\n${processSnapshot}`); }