diff --git a/NativeScript/ffi/objc/hermes/NativeApiJsi.h b/NativeScript/ffi/objc/hermes/NativeApiJsi.h index e98ba0431..f2bd4d1f1 100644 --- a/NativeScript/ffi/objc/hermes/NativeApiJsi.h +++ b/NativeScript/ffi/objc/hermes/NativeApiJsi.h @@ -18,6 +18,42 @@ void InstallNativeApiJSI( facebook::jsi::Runtime& runtime, const NativeApiJsiConfig& config = NativeApiJsiConfig{}); +// M1 (ARCHITECTURE.md §4.3): the two ObjC<->JSI helpers that make the +// by-reference Fabric handoff possible; "the Fabric boundary must hand JS +// a real bridge-wrapped object, not a string handle" +// (CLEANUP_AND_REARCHITECTURE_PLAN.md §2.0). Both wrap the SAME +// NativeApiObjectHostObject mechanism every other native object crossing in +// this bridge already uses (Object.mm/Class.mm); so a wrapped value +// round-trips through the identical `nativeValue(...)`-style method dispatch +// as any other bridged object, not a bespoke RPC. +// +// `object`/the return value are `void*`-typed ObjC `id`s, kept untyped here +// (not `id`) so this header stays includable from a plain C++ translation +// unit that never imports Objective-C (e.g. runtime/apple/Runtime.cpp, +// which includes this header under `#ifdef TARGET_ENGINE_HERMES` without +// itself being compiled as Objective-C++); the same convention +// NativeApiBackendConfig.h already follows. +// +// Only implemented for the Hermes backend (this header/its .mm are +// Hermes-only, ffi/objc/hermes/); RN only ever uses Hermes, so this does not +// touch the V8/JSC/QuickJS engine backends or their standalone builds. +// +// If `ownsObject` is true, the wrapper takes over a +1 retain already held +// by the caller (matching `makeNativeObjectValue`'s `ownsObject` semantics +// used everywhere else in the bridge); if false, the wrapper retains its own +// reference and the caller's reference is untouched. +facebook::jsi::Value NativeScriptWrapNativeObject(facebook::jsi::Runtime& runtime, + void* object, + bool ownsObject = false); + +// Reverse direction: given a JSI value produced by NativeScriptWrapNativeObject +// (or any other native-object-wrapping mechanism the bridge already uses -- +// NativeApiObjectHostObject, NativeApiPointerHostObject, +// NativeApiReferenceHostObject), returns the underlying native pointer, or +// nullptr if `value` does not wrap a live native object. +void* NativeScriptUnwrapNativeObject(facebook::jsi::Runtime& runtime, + const facebook::jsi::Value& value); + } // namespace nativescript extern "C" void NativeScriptInstallNativeApiJSI( diff --git a/NativeScript/ffi/objc/hermes/NativeApiJsi.mm b/NativeScript/ffi/objc/hermes/NativeApiJsi.mm index 6039fb8a4..da41fafb1 100644 --- a/NativeScript/ffi/objc/hermes/NativeApiJsi.mm +++ b/NativeScript/ffi/objc/hermes/NativeApiJsi.mm @@ -170,7 +170,7 @@ NativeApiSelectorGroupState state( } // GSD fast path: read jsi args directly, call objc_msgSend with a - // typed cast, produce the jsi return value — bypassing all generic + // 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). if (call.prepared->gsdEngineCallable && call.dispatchClass == Nil && @@ -215,6 +215,49 @@ void InstallNativeApiJSI(Runtime& runtime, const NativeApiJsiConfig& config) { InstallNativeApi(runtime, config); } +namespace { +// The bridge for a given runtime is reached the same way every other +// caller finds it: the `NativeApiHostObject` stashed under the well-known +// global name every InstallNativeApi call uses by default +// (NativeApiBackendConfig::globalName, "__nativeScriptNativeApi") -- +// identical to how `nativeApiInstalled()` in NativeScriptNativeApiModule.mm +// already probes for this same global. +std::shared_ptr NativeScriptBridgeForRuntime(Runtime& runtime) { + Value apiValue = runtime.global().getProperty(runtime, "__nativeScriptNativeApi"); + if (!apiValue.isObject()) { + return nullptr; + } + Object apiObject = apiValue.asObject(runtime); + if (!apiObject.isHostObject(runtime)) { + return nullptr; + } + return apiObject.getHostObject(runtime)->bridge(); +} +} // namespace + +Value NativeScriptWrapNativeObject(Runtime& runtime, void* object, bool ownsObject) { + if (object == nullptr) { + return Value::null(); + } + auto bridge = NativeScriptBridgeForRuntime(runtime); + if (bridge == nullptr) { + return Value::null(); + } + // __bridge: a plain ownership-neutral cast, valid identically whether this + // translation unit is compiled ARC or MRC (unlike a raw C-style cast, + // which ARC rejects for void* <-> id without an explicit bridge + // annotation). + return makeNativeObjectValue(runtime, bridge, (__bridge id)object, ownsObject); +} + +void* NativeScriptUnwrapNativeObject(Runtime& runtime, const Value& value) { + void* pointer = nullptr; + if (readPointerLikeValue(runtime, value, &pointer)) { + return pointer; + } + return nullptr; +} + } // namespace nativescript extern "C" void NativeScriptInstallNativeApiJSI(facebook::jsi::Runtime* runtime, diff --git a/NativeScript/ffi/objc/shared/bridge/HostObject.mm b/NativeScript/ffi/objc/shared/bridge/HostObject.mm index d16a91f96..0023613c0 100644 --- a/NativeScript/ffi/objc/shared/bridge/HostObject.mm +++ b/NativeScript/ffi/objc/shared/bridge/HostObject.mm @@ -23,6 +23,14 @@ inline bool InstallNativeApiLazyGlobal( explicit NativeApiHostObject(std::shared_ptr bridge) : bridge_(std::move(bridge)) {} + // General accessor (not RN-specific): lets any caller holding the + // per-runtime `__nativeScriptNativeApi` global's HostObject recover the + // underlying bridge, e.g. to wrap/unwrap a native object into an engine + // value via the same mechanism every other crossing already uses (see the + // Hermes-backend wrap/unwrap helpers, ffi/objc/hermes/; this file stays + // engine-neutral and does not itself reference any engine-specific type). + const std::shared_ptr& bridge() const { return bridge_; } + Value get(Runtime& runtime, const PropNameID& name) override { std::string property = name.utf8(runtime); if (property == "runtime") { diff --git a/package.json b/package.json index e77e44af8..4782969be 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "build-rn-turbomodule": "./scripts/build_react_native_turbomodule.sh", "check:ffi-boundaries": "./scripts/check_ffi_boundaries.sh", "test-rn-turbomodule": "./scripts/test_react_native_turbomodule.sh", + "test-rn-screens-m2": "./scripts/test_react_native_screens_m2.sh", "test-rn-ffi": "./scripts/test_react_native_ffi_compat.sh", "demo-rn-turbomodule": "./scripts/create_react_native_demo.sh", "pack:ios": "./scripts/build_npm_ios.sh", diff --git a/packages/react-native-screens/LICENSE b/packages/react-native-screens/LICENSE new file mode 100644 index 000000000..6f231e7ca --- /dev/null +++ b/packages/react-native-screens/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 Yagiz Nizipli and Daniel Lemire + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/packages/react-native-screens/package.json b/packages/react-native-screens/package.json new file mode 100644 index 000000000..24bfb827c --- /dev/null +++ b/packages/react-native-screens/package.json @@ -0,0 +1,41 @@ +{ + "name": "@nativescript/react-native-screens", + "version": "0.0.1", + "description": "UINavigationController-backed native stack for React Native, written against @nativescript/react-native's defineNativeComponent API", + "keywords": [ + "NativeScript", + "React Native", + "react-native-screens", + "navigation", + "iOS" + ], + "repository": { + "type": "git", + "url": "https://github.com/NativeScript/napi-ios", + "directory": "packages/react-native-screens" + }, + "author": { + "name": "NativeScript Team", + "email": "oss@nativescript.org" + }, + "license": "Apache-2.0", + "main": "src/index.ts", + "react-native": "src/index.ts", + "types": "src/index.ts", + "files": [ + "src", + "README.md", + "LICENSE" + ], + "peerDependencies": { + "@nativescript/react-native": "*", + "react": "*", + "react-native": ">=0.79", + "react-native-worklets": ">=0.8.0" + }, + "peerDependenciesMeta": { + "react-native-worklets": { + "optional": true + } + } +} diff --git a/packages/react-native-screens/src/index.ts b/packages/react-native-screens/src/index.ts new file mode 100644 index 000000000..df0f95ce1 --- /dev/null +++ b/packages/react-native-screens/src/index.ts @@ -0,0 +1,458 @@ +/** + * @nativescript/react-native-screens + * + * A UINavigationController-backed native stack, written in pure TypeScript + * against @nativescript/react-native's `defineNativeComponent` API; no + * native code, no codegen, no bespoke ComponentView subclass. Two Fabric + * components: + * + * one UINavigationController; owns push/pop/modal. + * one UIViewController per screen. + * + * Mounting discipline matches upstream react-native-screens + * (RNSScreenStack.mm): a Screen is never a plain Fabric subview; mounting + * one is an array insert into the stack's own `screens` list, and the real + * `UINavigationController.viewControllers` array is reconciled in ONE + * deferred update per mounting transaction, gated behind UIKit's own + * `transitionCoordinator` so JS never mutates the stack mid-transition. + * Modal present/dismiss goes through the same funnel and the same gate. + */ +import { defineNativeComponent, type NSComponentContext } from "@nativescript/react-native"; +import type { ViewProps } from "react-native"; + +export type StackPresentation = "push" | "modal"; + +export type ScreenProps = { + /** 0 = not in the native stack right now; anything else = present in it. + * A screen can leave the stack via this prop without unmounting (matches + * upstream RNS's activityState contract). */ + activityState: number; + stackPresentation: StackPresentation; + title: string; + headerShown: boolean; + headerBackTitle: string; +}; + +export type ScreenEvents = { + onAppear: Record; + onDisappear: Record; + /** Fires when UIKit removed this screen from the stack WITHOUT React + * asking it to; i.e. an interactive back-swipe gesture completed. + * `dismissCount` lets a caller de-dupe repeat/stale events. */ + onDismissed: { dismissCount: number }; +}; + +type ScreenInstance = { + controller: any; + emit: (name: string, payload?: unknown) => void; + stack?: StackInstance; + tag: number; + activityState: number; + stackPresentation: StackPresentation; + headerShown: boolean; + dismissCount: number; +}; + +export const Screen = defineNativeComponent({ + name: "NSScreen", + props: { + activityState: 2, + stackPresentation: "push", + title: "", + headerShown: true, + headerBackTitle: "", + }, + events: ["onAppear", "onDisappear", "onDismissed"], + // RNSScreen.mm:1193's own default; torn down through -invalidate + // (NativeScriptComponentView's viaInvalidate=true path), never pooled. + shouldBeRecycled: false, + + create(ctx) { + "worklet"; + const g = globalThis as any; + const vc = g.UIViewController.alloc().init(); + vc.view = ctx.view; // the Fabric ComponentView IS this screen's UIView. + // M3 item 1 fix landed upstream (defineNativeComponent's buildViewConfig + // no longer clobbers the base validAttributes.style descriptor); a + // plain `style={{flex: 1}}` on this component now reaches Yoga like any + // other Fabric view, so the ctx.setContentSize(UIScreen bounds) hack this + // comment used to describe is gone. See ScreenStack.create()'s own note. + ctx.instance.controller = vc; + ctx.instance.emit = ctx.emit; + ctx.instance.tag = ctx.tag; + ctx.instance.activityState = 2; + ctx.instance.stackPresentation = "push"; + ctx.instance.headerShown = true; + ctx.instance.dismissCount = 0; + }, + + // `next` is NOT a guaranteed full prop snapshot; confirmed on-sim: a + // commit that only changes `activityState` (e.g. a sibling screen + // pushing/popping re-renders this one with an unchanged `stackPresentation` + // JSX literal) can hand `updateProps` a `next` where `stackPresentation`/ + // `title` arrive as `undefined`, even though the component's real current + // value hasn't changed. Overwriting unconditionally (this component's + // first shape) silently reclassified push screens as non-push mid-flight + // (`reconcilePushStack` then filtered them out of `viewControllers`) and + // blanked already-set header titles. MERGE onto existing instance state -- + // only apply a field when `next` actually carries it; never overwrite + // blindly. + updateProps(ctx, next) { + "worklet"; + const inst = ctx.instance; + if (next.stackPresentation !== undefined) { + inst.stackPresentation = next.stackPresentation; + } + if (next.headerShown !== undefined) { + inst.headerShown = next.headerShown !== false; + } + if (next.title !== undefined) { + inst.controller.navigationItem.title = next.title || ""; + } + if (next.headerBackTitle) { + inst.controller.navigationItem.backButtonTitle = next.headerBackTitle; + } + if (next.activityState !== undefined && next.activityState !== inst.activityState) { + inst.activityState = next.activityState; + inst.stack?.scheduleUpdate(); + } + }, + + // UIKit owns this screen's frame once a stack has hosted it -- + // RNSScreen.mm:1348-1371's decline pattern. + updateLayoutMetrics(ctx) { + "worklet"; + return ctx.instance.stack === undefined; + }, + + prepareForRecycle(ctx) { + "worklet"; + ctx.instance.stack?.removeScreen(ctx.instance); + }, +}); + +export type StackEvents = { + onFinishTransitioning: Record; +}; + +type StackInstance = { + nav: any; + componentView: any; + containmentDone: boolean; + tag: number; + screens: ScreenInstance[]; + presentedModal?: ScreenInstance; + modalBusy: boolean; + transitionQueued: boolean; + currentTopTag?: number; + scheduleUpdate: () => void; + removeScreen: (screen: ScreenInstance) => void; +}; + +// --------------------------------------------------------------------------- +// Reconciliation, installed onto the UI runtime's own globalThis rather than +// left as plain module-level functions referencing each other by name. +// +// This is NOT stylistic. reconcileStack/reconcileModal are mutually +// recursive (reconcileStack re-enters itself via +// animateAlongsideTransitionCompletion; reconcileModal calls back into +// reconcileStack from a present/dismiss completion). A worklet function +// captured as a free variable from ANOTHER worklet's closure materializes +// correctly for a DIRECT reference (e.g. mountingTransactionDidMount calling +// reconcileStack(ctx) worked fine); but a CYCLE in that capture graph +// (two independently-declared worklet helpers that call each other) does +// not: react-native-worklets compiles every 'worklet' function into a +// `const NAME = factory(...)` binding (not hoisted; see +// `replaceWithFactoryCall` in its own babel plugin), so whichever of two +// mutually-referencing helpers is declared first is captured while the +// other's binding is still uninitialized; a `ReferenceError` in the +// direct-function-declaration form, confirmed by transforming sample code +// through the real plugin and executing the output (see +// `@nativescript/react-native`'s own `defineNativeComponent.ts`, which now +// throws a clear, named error at `defineNativeComponent()` call time for the +// depth-of-capture variant of this same hazard). The fix is the same either +// way: route the mutually-recursive calls through a stable globalThis +// property (a runtime lookup, not a captured closure) instead of a bare +// identifier reference. +type ReconcileHelpers = { + reportGestureDismissals(inst: StackInstance): void; + reconcileStack(ctx: NSComponentContext): void; + reconcilePushStack(nav: any, inst: StackInstance): void; + reconcileModal(ctx: NSComponentContext, inst: StackInstance): void; + attachContainmentIfNeeded(inst: StackInstance): void; +}; + +function ensureReconcileHelpersInstalled() { + "worklet"; + const g = globalThis as any; + if (g.__nsScreensHelpers) return; + + const helpers: ReconcileHelpers = { + // A UINavigationController grabbed by its bare `.view` and hung off a + // Fabric ComponentView WITHOUT real UIViewController containment + // (-addChildViewController:/-didMoveToParentViewController:) never lays + // out its content area or navigation bar correctly; confirmed on-sim + // (a completely blank screen, 0 accessibility nodes below the RN root, + // despite every create/mount/event hook firing and reporting success). + // Fabric ComponentViews are plain UIViews, not UIViewControllers, so + // there is nothing to containment-parent TO until this view is actually + // in a real UIViewController's responder chain; walk `nextResponder` + // (idempotent, cheap, retried on every reconcile) to find the nearest + // ancestor UIViewController (RN's own root view controller) the first + // time it's reachable. + attachContainmentIfNeeded(inst) { + "worklet"; + if (inst.containmentDone) return; + const g = globalThis as any; + const view = inst.componentView; + if (!view) return; + let responder = view.nextResponder; + while (responder != null) { + if (responder.isKindOfClass && responder.isKindOfClass(g.UIViewController)) { + responder.addChildViewController(inst.nav); + inst.nav.didMoveToParentViewController(responder); + inst.containmentDone = true; + return; + } + responder = responder.nextResponder; + } + }, + + reportGestureDismissals(inst) { + "worklet"; + const nav = inst.nav; + for (const screen of inst.screens) { + if ( + screen.stackPresentation === "push" && + screen.activityState !== 0 && + !nav.viewControllers.containsObject(screen.controller) + ) { + screen.activityState = 0; + screen.dismissCount += 1; + screen.emit("onDismissed", { dismissCount: screen.dismissCount }); + } + } + }, + + // THE discipline (RNSScreenStack.mm:596-609): never mutate + // viewControllers, present, or dismiss while UIKit already owns an + // active transition; defer via the SAME transitionCoordinator until it + // ends, then retry. Single funnel for both the push array and modal + // present/dismiss, so a prop change that arrives mid-gesture-swipe can + // never race UIKit's own mutation of the same array. + // + // M3 fix (item 3): this used to defer via a `ctx.scheduleOnMainQueue` + // poll loop instead of `transitionCoordinator.animateAlongsideTransition + // Completion(null, retryFn)` directly, because handing THAT specific + // call a real JS closure threw `Error: Native callback metadata is + // unavailable`; confirmed root cause (verified on-sim across several + // isolated call sites): `NativeApiBridge::findClassForRuntimeClass` + // (ObjCBridge.mm) resolves an Objective-C instance's method metadata by + // walking ONLY the concrete class hierarchy (`class_getSuperclass`), and + // never consults protocols the object conforms to. Every method of + // `UIViewControllerTransitionCoordinator` is declared SOLELY on that + // protocol, never on any real class in `transitionCoordinator`'s + // (private) class chain, so its completion-block parameter's inner + // signature has no metadata-sourced entry to find; the exact and only + // thing this specific call throws on. Ordinary CLASS-declared completion + // parameters do not have this problem at all: `presentViewController + // Animated:completion:`/`dismissViewControllerAnimated:completion:` are + // declared directly on `UIViewController` (found immediately via the + // class-hierarchy walk) and pass a real JS closure with no wrapping, + // confirmed working on-sim below. For the ONE protocol-only call site, + // the runtime's own existing (and otherwise undocumented for this + // purpose) `interop.Block(fn, objcEncoding)` escape hatch supplies the + // missing signature manually; `"v@?@"` = void return, block-self + // marker, one object argument (the transition context); and bypasses + // metadata lookup entirely; confirmed firing correctly on-sim. + reconcileStack(ctx) { + "worklet"; + const inst = ctx.instance; + const nav = inst.nav; + const g = globalThis as any; + + g.__nsScreensHelpers.attachContainmentIfNeeded(inst); + + if (nav.transitionCoordinator != null) { + if (inst.transitionQueued) return; + inst.transitionQueued = true; + const retry = g.interop.Block(() => { + "worklet"; + inst.transitionQueued = false; + (globalThis as any).__nsScreensHelpers.reconcileStack(ctx); + }, "v@?@"); + nav.transitionCoordinator.animateAlongsideTransitionCompletion(null, retry); + return; + } + + g.__nsScreensHelpers.reconcilePushStack(nav, inst); + g.__nsScreensHelpers.reconcileModal(ctx, inst); + }, + + reconcilePushStack(nav, inst) { + "worklet"; + const vcs = inst.screens + .filter((s) => s.activityState !== 0 && s.stackPresentation === "push") + .map((s) => s.controller); + if (vcs.length === 0 || nav.viewControllers.isEqualToArray(vcs)) return; + const animated = vcs.length !== nav.viewControllers.count; + nav.setViewControllersAnimated(vcs, animated); + }, + + // M3 fix (item 3): `presentViewControllerAnimated:completion:` and + // `dismissViewControllerAnimated:completion:` are declared directly on + // `UIViewController`; a real class, found immediately by the class- + // hierarchy walk `NativeApiBridge::findClassForRuntimeClass` performs -- + // so, unlike `animateAlongsideTransitionCompletion` above, these two + // NEVER hit the protocol-metadata gap: a real JS closure passed straight + // to their completion parameter fires correctly, confirmed on-sim. No + // `interop.Block` wrapping, no poll; the completion IS the state + // transition. + reconcileModal(ctx, inst) { + "worklet"; + if (inst.modalBusy) return; + const desired = inst.screens.find((s) => s.stackPresentation === "modal" && s.activityState !== 0); + + if (inst.presentedModal && inst.presentedModal !== desired) { + const dismissed = inst.presentedModal; + inst.modalBusy = true; + inst.nav.dismissViewControllerAnimatedCompletion(true, () => { + "worklet"; + inst.modalBusy = false; + if (inst.presentedModal === dismissed) inst.presentedModal = undefined; + // Modal screens never become the nav controller's topViewController, + // so they never reach UINavigationControllerDelegate's didShow -- + // that is the ONLY place push screens' onAppear/onDisappear come + // from (see the stack's own delegate, above). Fire the equivalent + // signal here, from the one place that actually knows a modal + // present/dismiss completed. + dismissed.emit("onDisappear", {}); + // `onDismissed` is the general "this screen is no longer in the + // stack/no longer presented" signal; fired here for a + // programmatic dismiss (e.g. a Close button) exactly as it is for + // a gesture-driven pop (reportGestureDismissals), so a caller has + // one event to listen to regardless of what triggered removal. + dismissed.dismissCount += 1; + dismissed.emit("onDismissed", { dismissCount: dismissed.dismissCount }); + (globalThis as any).__nsScreensHelpers.reconcileStack(ctx); + }); + return; + } + + if (desired && inst.presentedModal !== desired) { + inst.presentedModal = desired; + inst.modalBusy = true; + inst.nav.presentViewControllerAnimatedCompletion(desired.controller, true, () => { + "worklet"; + inst.modalBusy = false; + desired.emit("onAppear", {}); + (globalThis as any).__nsScreensHelpers.reconcileStack(ctx); + }); + } + }, + }; + + g.__nsScreensHelpers = helpers; +} + +export const ScreenStack = defineNativeComponent({ + name: "NSScreenStack", + events: ["onFinishTransitioning"], + shouldBeRecycled: false, + + create(ctx) { + "worklet"; + const g = globalThis as any; + ensureReconcileHelpersInstalled(); + const nav = g.UINavigationController.alloc().init(); + + const inst = ctx.instance; + inst.nav = nav; + inst.componentView = ctx.view; + inst.containmentDone = false; + // M3 item 1 fix: `defineNativeComponent`'s buildViewConfig() used to set + // validAttributes.style = true, which clobbered the base config's real + // ReactNativeStyleAttributes descriptor and silently dropped every + // style/layout prop before it ever reached this shadow node's Yoga style + //; NOT a Yoga/native/adopt() defect. Fixed upstream (defineNativeComponent.ts); + // `style={{flex: 1}}` on now produces a real, correct Yoga + // height (confirmed on-sim: width=402 height=874, a real device size, + // with no ctx.setContentSize call anywhere in this file). + inst.tag = ctx.tag; + inst.screens = []; + inst.modalBusy = false; + inst.transitionQueued = false; + inst.scheduleUpdate = () => { + "worklet"; + g.__nsScreensHelpers.reconcileStack(ctx); + }; + inst.removeScreen = (screen: ScreenInstance) => { + "worklet"; + const idx = inst.screens.indexOf(screen); + if (idx >= 0) inst.screens.splice(idx, 1); + screen.stack = undefined; + // Deliberately NOT clearing `inst.presentedModal` here, even if it + // points at `screen`: a modal unmounted directly (React removing it + // from the tree instead of first flipping activityState to 0) must + // still be dismissed through `reconcileModal`'s real + // dismissViewControllerAnimatedCompletion call, which reads + // `presentedModal` to know what to dismiss. `reconcileModal` clears + // it itself once the dismiss actually completes. + }; + + // Per-screen header visibility: UINavigationBar is one shared bar per + // stack, so it is toggled as each screen becomes topmost; the same + // trade upstream RNS makes (willShow, before the screen is on screen). + nav.delegate = ctx.createDelegate("UINavigationControllerDelegate", { + navigationControllerWillShowViewControllerAnimated(navController: any, viewController: any, animated: boolean) { + const shownTag = viewController.view.tag; + const shown = inst.screens.find((s) => s.tag === shownTag); + if (shown) navController.setNavigationBarHiddenAnimated(!shown.headerShown, animated); + }, + navigationControllerDidShowViewControllerAnimated(navController: any, viewController: any) { + const newTopTag = viewController.view.tag; + if (inst.currentTopTag !== undefined && inst.currentTopTag !== newTopTag) { + inst.screens.find((s) => s.tag === inst.currentTopTag)?.emit("onDisappear", {}); + } + inst.screens.find((s) => s.tag === newTopTag)?.emit("onAppear", {}); + inst.currentTopTag = newTopTag; + + g.__nsScreensHelpers.reportGestureDismissals(inst); + ctx.emit("onFinishTransitioning", {}); + g.__nsScreensHelpers.reconcileStack(ctx); + }, + }); + + return nav.view; + }, + + // The entire mount: an array insert. RNSScreenStack.mm:1283-1302, verbatim + //; declaring this hook means Fabric's default `[super mountChild...]` + // (which would make the screen a plain subview) never runs. + mountChildComponentView(ctx, child, index) { + "worklet"; + const screen = child.instance as ScreenInstance | undefined; + if (!screen) return; + ctx.instance.screens.splice(index, 0, screen); + screen.stack = ctx.instance; + }, + + unmountChildComponentView(ctx, child) { + "worklet"; + const screen = child.instance as ScreenInstance | undefined; + if (screen) ctx.instance.removeScreen(screen); + }, + + // ONE deferred container update per transaction that actually touched our + // children. RNSScreenStack.mm:1349-1366, verbatim. + mountingTransactionDidMount(ctx, txn) { + "worklet"; + const matched = txn.didMutateChildrenOf(ctx.tag); + if (matched) { + ctx.scheduleOnMainQueue(() => { + "worklet"; + (globalThis as any).__nsScreensHelpers.reconcileStack(ctx); + }); + } + }, +}); diff --git a/packages/react-native/README.md b/packages/react-native/README.md index b9ebc174c..02565b6f9 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -1,12 +1,37 @@ # @nativescript/react-native -React Native TurboModule wrapper for the NativeScript Native API JSI bridge on -Hermes. +This package exposes the NativeScript Objective-C bridge to React Native through +a Hermes TurboModule. It also provides `defineNativeComponent`, which defines +Fabric components in TypeScript without codegen or project-specific native code. -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. +## Threading model + +React Native runs the React tree and event handlers on its JS thread. When +`react-native-worklets` is installed, it adds a second Hermes VM on the main +thread. This package installs the NativeScript Objective-C bridge in both VMs. + +- `NativeScript.init()` installs the bridge on the React Native JS thread. + Native globals are disabled there unless you call `init({ globals: true })`. +- `NativeScript.scheduleOnUI()` and `defineNativeComponent` use the Worklets UI + runtime. Native globals are always enabled in that VM, and every component + hook runs on the main thread. + +A component hook is a worklet. Fabric invokes it on the main thread, so it can +call UIKit directly without another queue or batching layer. + +Four React Native mechanisms cross the JS and UI boundary: + +1. Fabric sends changed props to `updateProps` during a commit. +2. `ctx.emit(name, payload)` sends events through Fabric's event emitter. +3. `dispatchNativeComponentCommand(ref, name, args)` invokes a component's + command handler through Fabric. +4. `NativeScript.jsInvoker()` sends a native callback to the JS thread through + Worklets' `scheduleOnRN`. + +Do not add JSON transport, string handles, a separate batching queue, or +method swizzling for this boundary. See [Design limits](#design-limits). + +## Setup ```ts import NativeScript from "@nativescript/react-native"; @@ -17,12 +42,12 @@ 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. +`react-native-worklets` UI runtime. `NativeScript.scheduleOnUI()` only accepts +Worklets callbacks; running React Native's JS-thread runtime as a UI-thread +shim is not supported. ```ts -await NativeScript.runOnUI(() => { +await NativeScript.scheduleOnUI(() => { "worklet"; UIApplication.sharedApplication.keyWindow.tintColor = UIColor.systemPinkColor; }); @@ -46,7 +71,7 @@ module.exports = { ``` `installWorklets()` is still exported for custom initialization, but it throws -when Worklets is unavailable or incompatible. `runOnUI()` throws when the +when Worklets is unavailable or incompatible. `scheduleOnUI()` throws when the callback was not transformed into a Worklets function. Obj-C blocks and JS-backed Obj-C method callbacks, including `NSObject.extend` @@ -64,11 +89,6 @@ UIView.animateWithDurationAnimationsCompletion( ); ``` -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: ```ts @@ -80,357 +100,335 @@ someNativeApi(() => { 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. +`NativeScript.scheduleOnUI()` instead. -## Defining native UIKit views in JS +`@nativescript/react-native/babel-plugin` adds the `"worklet"` directive to +Fabric hooks and command handlers inside a `defineNativeComponent` spec. +Examples still include the directive because extracted helper functions need +their own directive. -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. +## `defineNativeComponent` -```tsx -import NativeScript, { defineUIKitView } from "@nativescript/react-native"; -import type { UIKitViewRef } from "@nativescript/react-native"; +```ts +import { defineNativeComponent } from "@nativescript/react-native"; +``` -NativeScript.init(); +One call defines a component name, prop defaults, events, and lifecycle hooks. +It returns a typed `HostComponent`. The package builds its view config at +runtime through `NativeComponentRegistry.get`. -type BadgeProps = { - title: string; - tone?: "blue" | "green"; +```ts +type NativeComponentSpec = { + name: string; + props?: Props; // defaults; keys become validAttributes + events?: (keyof Events & string)[]; // "onXxx" -> Fabric's "topXxx" + shouldBeRecycled?: boolean; // default: recycled like any Fabric view + + create?(ctx): unknown | void; + updateProps?(ctx, next: Partial, prev: Partial): void; + mountChildComponentView?(ctx, child, index): void; + unmountChildComponentView?(ctx, child, index): void; + mountingTransactionWillMount?(ctx, txn): void; + mountingTransactionDidMount?(ctx, txn): void; + updateLayoutMetrics?(ctx, next, prev): boolean; + finalizeUpdates?(ctx, mask: number): void; + prepareForRecycle?(ctx, viaInvalidate: boolean): void; + commands?: Record void>; }; +``` + +- `name` is the Fabric component name. +- `props` supplies defaults. Its keys become the component's + `validAttributes`; the `Props` generic defines their types. Do not add + `style`. The inherited view config already contains React Native's style + descriptor, and replacing it prevents Yoga props from reaching the shadow + node. +- `events` contains names such as `onAppear`. The function rejects names that + do not start with `on` followed by an uppercase letter. +- `shouldBeRecycled: false` makes Fabric dispose of the view through + `-invalidate` instead of its recycle pool. `prepareForRecycle` runs on both + paths and receives the chosen path in `viaInvalidate`. +- Every hook is a worklet on the main thread. If the Babel plugin does not add + the directive, write `"worklet"` at the start of the hook. + +### The hooks + +- `create(ctx)` runs first and once per instance. Store component state on + `ctx.instance`. A returned `UIView` becomes the component's `contentView`. + With no return value, `ctx.view` remains the content view. +- `updateProps(ctx, next, prev)` receives partial updates. Check whether each + key is present and merge it into `ctx.instance`. +- `mountChildComponentView` and `unmountChildComponentView` replace Fabric's + default behavior independently. Define both when the component owns child + mounting. `child` contains `{ tag, view, instance }`. +- `mountingTransactionWillMount` and `mountingTransactionDidMount` run before + and after a transaction that touches the tag's tree. Use + `txn.didMutateChildrenOf(tag)` to check whether the transaction changed its + children. Defer UIKit containment changes with `ctx.scheduleOnMainQueue`. +- `updateLayoutMetrics` can return `false` when the component owns its frame. +- `finalizeUpdates` runs after the other update hooks in a commit. Its `mask` + argument is React Native's `RNComponentViewUpdateMask` value. +- `prepareForRecycle` is the final hook. Release retained helpers there. +- `commands` maps names to worklet handlers. Invoke them with + `dispatchNativeComponentCommand(ref.current, "name", args)`. + +## `ctx` + +Every hook receives an `NSComponentContext`: + +| Member | What it does | Safe from | +|---|---|---| +| `ctx.view` | This instance's `NativeView` (the Fabric `ComponentView`, or the raw view for a `child` argument). | every hook | +| `ctx.tag` | The Fabric react tag. | every hook | +| `ctx.instance` | Your mutable per-tag object (`Instance`). Empty at `create`; the same object every other hook for this tag gets back. State lives here, never as an expando on `ctx.view`. | every hook after `create` populates it | +| `ctx.emit(name, payload?)` | Dispatches a declared event. Fired-before-mounted events are buffered natively and flushed once Fabric's own emitter attaches. | every hook, including `create` | +| `ctx.setContentSize(size, opts?)` | Writes a UIKit-measured size into the shadow tree's `State`. `opts.authority` can make it override Yoga's measurement. Calls made before Fabric supplies state are buffered. | every hook, including `create` | +| `ctx.scheduleOnMainQueue(fn)` | Defers `fn` by one main run-loop turn with `dispatch_async`. Use it before changing UIKit containment during a Fabric mounting transaction. | every hook | +| `ctx.createDelegate(protocols, methods, options?)` | Same function as top-level `NativeScript.createDelegate`, forwarded so a hook doesn't need a second import. | every hook | +| `ctx.instanceForView(view)` | Looks up a tracked `Instance` from a `NativeView` and its Fabric tag. | every hook | + +## Worked example + +A minimal component: one prop, one command, no children. -export const NativeBadge = defineUIKitView({ +```ts +import { defineNativeComponent, dispatchNativeComponentCommand } from "@nativescript/react-native"; + +type BadgeProps = { text: string }; +type BadgeInstance = { label: any }; + +export const NativeBadge = defineNativeComponent, BadgeInstance>({ 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; + props: { text: "" }, + shouldBeRecycled: false, + + create(ctx) { + "worklet"; + const g = globalThis as any; + const label = g.UILabel.alloc().initWithFrame(g.CGRectZero); + label.textAlignment = g.NSTextAlignment.Center; + label.textColor = g.UIColor.whiteColor; + label.backgroundColor = g.UIColor.systemBlueColor; + label.layer.cornerRadius = 8; + label.clipsToBounds = true; + ctx.instance.label = label; + return label; // installed as this component's contentView + }, + + updateProps(ctx, next) { + "worklet"; + if (next.text !== undefined) ctx.instance.label.text = next.text; }, - 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; + + commands: { + setTone(ctx, args) { + "worklet"; + const g = globalThis as any; + const tone = args[0] as string; + ctx.instance.label.backgroundColor = + tone === "green" ? g.UIColor.systemGreenColor : g.UIColor.systemBlueColor; + }, }, }); -; +// +dispatchNativeComponentCommand(badgeRef.current, "setTone", ["green"]); ``` -Forward a ref when you need imperative access: - -```tsx -const badgeRef = useRef>(null); - -await badgeRef.current?.runOnUI((view) => { - "worklet"; - view.alpha = 0.8; -}); +For events, child mounting, transactions, and `UIViewController` containment, +see `packages/react-native-screens/src/index.ts`. It implements a +`UINavigationController` stack in TypeScript. Its `Screen` component merges +partial prop updates into stored state: -const measured = await badgeRef.current?.measureNative(); -badgeRef.current?.invalidateNativeLayout(); -``` +```ts +export const Screen = defineNativeComponent({ + name: "NSScreen", + props: { activityState: 2, stackPresentation: "push", title: "", headerShown: true, headerBackTitle: "" }, + events: ["onAppear", "onDisappear", "onDismissed"], + shouldBeRecycled: false, -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 - -`create`, `update`, `mounted`, and `dispose` run through the UIKit path. You do -not need to wrap UIKit work in `runOnUI()` inside those callbacks. - -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: - -```tsx -export const NativeSwitch = NativeScript.defineUIKitView< - { value: boolean; onValueChange?: (value: boolean) => void }, - UISwitch ->({ - name: "NativeSwitch", - layout: { sizing: "intrinsic" }, create(ctx) { - const view = UISwitch.new(); - ctx.targetAction(view, UIControlEvents.ValueChanged, () => { - ctx.emit("onValueChange", view.on); - }); - return view; + "worklet"; + const g = globalThis as any; + const vc = g.UIViewController.alloc().init(); + vc.view = ctx.view; // the Fabric ComponentView IS this screen's UIView. + ctx.instance.controller = vc; + ctx.instance.emit = ctx.emit; + ctx.instance.activityState = 2; + // ... }, - update(view, props) { - if (view.on !== props.value) { - view.setOnAnimated(props.value, false); + + updateProps(ctx, next) { + "worklet"; + const inst = ctx.instance; + if (next.title !== undefined) inst.controller.navigationItem.title = next.title || ""; + if (next.activityState !== undefined && next.activityState !== inst.activityState) { + inst.activityState = next.activityState; + inst.stack?.scheduleUpdate(); } + // Guard every field because Fabric sends partial prop updates. }, -}); -``` -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. + updateLayoutMetrics(ctx) { + "worklet"; + return ctx.instance.stack === undefined; // decline once a stack hosts this screen + }, -### State, delegates, and retention + prepareForRecycle(ctx) { + "worklet"; + ctx.instance.stack?.removeScreen(ctx.instance); + }, +}); +``` -Native proxies support JavaScript expando properties for local state. Native -property setters still win first, and unsupported names fall back to JS state: +`ScreenStack` owns child mounting and defers UIKit updates until Fabric +finishes its mounting transaction: ```ts -NativeScript.runOnUI(() => { - "worklet"; - const view = UIView.new(); - view.ownerState = { selected: false }; - view.tag = 42; // still calls UIKit's native tag setter -}); -``` +export const ScreenStack = defineNativeComponent({ + name: "NSScreenStack", + events: ["onFinishTransitioning"], -Use `WeakMap`, React state, or another external object when you want state that -is not tied to the lifetime of a specific native proxy. + create(ctx) { + "worklet"; + const nav = (globalThis as any).UINavigationController.alloc().init(); + ctx.instance.nav = nav; + ctx.instance.componentView = ctx.view; + ctx.instance.screens = []; + nav.delegate = ctx.createDelegate("UINavigationControllerDelegate", { + navigationControllerDidShowViewControllerAnimated(navController, viewController) { + // ... emits onAppear/onDisappear, reconciles gesture-driven pops + }, + }); + return nav.view; // this component's contentView is the nav controller's own view + }, -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: + // The stack stores children instead of mounting them as plain subviews. + mountChildComponentView(ctx, child, index) { + "worklet"; + const screen = child.instance as ScreenInstance | undefined; + if (!screen) return; + ctx.instance.screens.splice(index, 0, screen); + screen.stack = ctx.instance; + }, -```ts -const retainer = NativeScript.createRetainer(); + unmountChildComponentView(ctx, child) { + "worklet"; + const screen = child.instance as ScreenInstance | undefined; + if (screen) ctx.instance.removeScreen(screen); + }, -const delegate = NativeScript.createDelegate( - UIScrollViewDelegate, - { - scrollViewDidScroll(scrollView) { - NativeScript.runOnUI(() => { + // Reconcile once after a transaction changes this stack's children. + mountingTransactionDidMount(ctx, txn) { + "worklet"; + if (txn.didMutateChildrenOf(ctx.tag)) { + ctx.scheduleOnMainQueue(() => { "worklet"; - scrollView.indicatorStyle = UIScrollViewIndicatorStyle.White; + (globalThis as any).__nsScreensHelpers.reconcileStack(ctx); }); - }, + } }, - { retainer }, -); +}); +``` -scrollView.delegate = delegate; +`ensureReconcileHelpersInstalled()` contains the helpers that update the +navigation controller and attach view controllers through the responder chain. -// Later, when the owner is done: -scrollView.delegate = null; -retainer.dispose(); -``` +## Hazards -`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()`: +### Mutually recursive worklets -```ts -NativeScript.loadFramework("QuickLook"); +Worklets' Babel plugin +desugars every `"worklet"`-directed function into a `const NAME = +factory(...)` binding, which is not hoisted. Two helpers that call each other +directly crash with `ReferenceError: Cannot access 'X' before initialization` no +matter which is declared first, because whichever is captured first is +captured while the other's binding is still uninitialized. Self-recursion +works through Worklets' `this._recur` mechanism. `defineNativeComponent` +checks each hook's closure when the component is defined and reports the +capture chain. Put mutually dependent helpers on a stable object and call them +through property lookup: -const dataSource = NativeScript.createDelegate( - "QLPreviewControllerDataSource", - { - numberOfPreviewItemsInPreviewController() { - return 1; +```ts +function ensureReconcileHelpersInstalled() { + "worklet"; + const g = globalThis as any; + if (g.__nsScreensHelpers) return; + g.__nsScreensHelpers = { + reconcileStack(ctx) { + "worklet"; + // ... calls g.__nsScreensHelpers.reconcileModal(ctx, inst) by lookup, not by name }, - previewControllerPreviewItemAtIndex() { - return NSURL.fileURLWithPath(path); + reconcileModal(ctx, inst) { + "worklet"; + // ... calls g.__nsScreensHelpers.reconcileStack(ctx) by lookup, not by name }, - }, - { 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 +### Protocol completion blocks -React Native owns placement through Yoga. UIKit owns native behavior inside the -placed rectangle. Use `layout.sizing` to opt into native measurement: +A method whose completion parameter is declared only on a protocol may throw +`Error: Native callback metadata is unavailable` when passed a plain JS +closure. The bridge cannot infer the block signature from the concrete class. +Supply the signature with `interop.Block`: -- `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(); - }, -}); +```ts +const retry = (globalThis as any).interop.Block(() => { + "worklet"; + // ... +}, "v@?@"); // void return, block-self, one object argument +nav.transitionCoordinator.animateAlongsideTransitionCompletion(null, retry); ``` -### Containers and view controllers - -Use `defineUIKitContainer()` when React Native children should mount inside a -UIKit-owned content view: - -```tsx -export const BlurCard = NativeScript.defineUIKitContainer({ - name: "BlurCard", - create() { - const rootView = UIVisualEffectView.alloc().initWithEffect( - UIBlurEffect.effectWithStyle(UIBlurEffectStyle.SystemMaterial), - ); - return { - rootView, - childrenView: rootView.contentView, - }; - }, -}); +Class-declared completion parameters do not need this wrapper. PR #71 adds +protocol lookup to the runtime, which will also remove this requirement. - - React Native child content -; -``` +### State on native proxies -Use `defineUIViewController()` for APIs that require real child view-controller -containment: +Do not store component state as an expando on `ctx.view` or another native +proxy. Use `ctx.instance`. `ctx.instanceForView` uses the Fabric tag for reverse +lookup. -```tsx -export const NativePageHost = NativeScript.defineUIViewController({ - name: "NativePageHost", - createController() { - return UIViewController.new(); - }, - update(controller) { - controller.view.backgroundColor = UIColor.systemBackgroundColor; - }, -}); -``` +### Serializing native objects -### Building app-specific native UI - -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: - -- 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. - -For example, build native tabs with `UITabBarController` instead of measuring a -standalone `UITabBar` as a leaf RN view: - -```tsx -type NativeTabsProps = { - selectedIndex: number; - onSelectedIndexChange?: (index: number) => void; -}; +Do not pass native objects to `JSON.stringify`, use them as plain-object keys, +or send `-description` to live callback arguments. Those operations inspect +bridge state and may crash while the native object is in use. -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; - }); +### Class lookup from worklets - 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; - }, -}); +`NativeScript.getClass` and `NativeScript.getProtocol` are JS-thread functions. +Calling either from a worklet throws `Tried to synchronously call a Remote +Function`. Native globals are already installed in the UI runtime, so access +the class through `globalThis`: -; +```ts +create(ctx) { + "worklet"; + const vc = (globalThis as any).UIViewController.alloc().init(); // not NativeScript.getClass("UIViewController") +}, ``` -For modal UIKit controllers, find the top visible presenter and guard against -double presentation: +## Design limits -```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; -} +The package does not add a batching layer, a second marshalling protocol, or +method swizzling. Fabric carries props, events, and commands. Hooks call UIKit +on the main thread. If a component needs JSON transport or string handles to +cross this boundary, report the missing bridge behavior instead of adding a +parallel transport. -await NativeScript.runOnUI(() => { - "worklet"; - const presenter = topVisibleViewController(); - if (!presenter || presenter.presentedViewController) { - return; - } - presenter.presentViewControllerAnimatedCompletion(controller, true, null); -}); -``` +For `UIViewController` containment beyond a single screen, build the controller +in `create`, return its `.view`, and attach it through the responder chain when +the component becomes reachable. See +`attachContainmentIfNeeded` in `packages/react-native-screens/src/index.ts`. -### Availability and heavy UIKit classes +## 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. +Use availability helpers before touching optional frameworks. Call them from +the JS thread, not a worklet. Simulator and device availability can differ for +such as VisionKit, QuickLook, and PassKit. ```ts if ( @@ -449,11 +447,44 @@ if ( 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. +Class globals installed through `NativeScript.init({ globals: true })` are +lazy. Large UIKit classes such as `UITabBarController` inherit many members. +Direct construction and member access remain lazy. `Object.keys`, prototype +introspection, and generated member lists force enumeration and cost more. + +## Retention and delegates outside `defineNativeComponent` + +UIKit often retains delegates and actions weakly or outlives the JavaScript +closure that created them. Retain those helper objects explicitly: + +```ts +const retainer = NativeScript.createRetainer(); + +const delegate = NativeScript.createDelegate( + UIScrollViewDelegate, + { + scrollViewDidScroll(scrollView) { + NativeScript.scheduleOnUI(() => { + "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. Use `NativeScript.retain(value)` / `NativeScript.release(value)` only +for process-lifetime helpers; prefer `createRetainer()` (or, inside a +`defineNativeComponent` hook, `ctx.createDelegate`'s own retention options) +for anything scoped to one component instance. 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 @@ -461,7 +492,8 @@ to JS errors where Objective-C can catch them. Process-level failures such as 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 package ships example native-API usage 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 @@ -498,7 +530,7 @@ npm run test-rn-turbomodule NativeScript.init(); - await NativeScript.runOnUI(() => { + await NativeScript.scheduleOnUI(() => { "worklet"; UIApplication.sharedApplication.keyWindow.tintColor = UIColor.systemPinkColor; @@ -557,29 +589,14 @@ Expo development build, EAS Build, or `npx expo run:ios`. npx expo run:ios ``` -4. Initialize NativeScript in app code before using native APIs: +4. Initialize NativeScript in app code before using native APIs, then define + native components as shown in [`defineNativeComponent`](#definenativecomponent) + above: ```tsx - import NativeScript, { defineUIKitView } from "@nativescript/react-native"; + import NativeScript from "@nativescript/react-native"; NativeScript.init(); - - 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 diff --git a/packages/react-native/examples/QuickLookPreviewController.tsx b/packages/react-native/examples/QuickLookPreviewController.tsx deleted file mode 100644 index eb748f57c..000000000 --- a/packages/react-native/examples/QuickLookPreviewController.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import NativeScript from '@nativescript/react-native'; - -export type QuickLookPreviewItem = { - path: string; -}; - -const quickLookState = new WeakMap(); - -export const QuickLookPreviewControllerHost = NativeScript.defineUIViewController<{ - items: QuickLookPreviewItem[]; -}>({ - name: 'QuickLookPreviewControllerHost', - layout: {sizing: 'fill'}, - createController(ctx) { - NativeScript.loadFramework('QuickLook'); - const PreviewController = - NativeScript.getClass('QLPreviewController'); - if (!PreviewController) { - throw new Error('QLPreviewController is not available'); - } - - const controller = PreviewController.new(); - const state = {items: ctx.items ?? []}; - quickLookState.set(controller, state); - - const dataSource = NativeScript.createDelegate( - 'QLPreviewControllerDataSource', - { - numberOfPreviewItemsInPreviewController() { - return state.items.length; - }, - previewControllerPreviewItemAtIndex(_controller, index) { - const item = state.items[index]; - return item ? NSURL.fileURLWithPath(item.path) : null; - }, - }, - {owner: ctx}, - ); - - controller.dataSource = dataSource; - ctx.dispose(() => { - controller.dataSource = null; - quickLookState.delete(controller); - }); - - return controller; - }, - update(controller, props) { - const state = quickLookState.get(controller); - if (state) { - state.items = props.items ?? []; - } - controller.reloadData(); - }, -}); diff --git a/packages/react-native/examples/UIKitContainer.tsx b/packages/react-native/examples/UIKitContainer.tsx deleted file mode 100644 index 167089953..000000000 --- a/packages/react-native/examples/UIKitContainer.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import NativeScript from '@nativescript/react-native'; - -export const UIKitContainer = NativeScript.defineUIKitContainer<{ - backgroundColor?: UIColor; -}>({ - name: 'UIKitContainer', - layout: {sizing: 'fill'}, - create() { - const rootView = UIView.new(); - const childrenView = UIView.new(); - childrenView.frame = rootView.bounds; - childrenView.autoresizingMask = - UIViewAutoresizing.FlexibleWidth | - UIViewAutoresizing.FlexibleHeight; - rootView.addSubview(childrenView); - return {rootView, childrenView}; - }, - update(view, props) { - view.rootView.backgroundColor = - props.backgroundColor ?? UIColor.clearColor; - }, -}); diff --git a/packages/react-native/examples/UIKitIntrinsicLabel.tsx b/packages/react-native/examples/UIKitIntrinsicLabel.tsx deleted file mode 100644 index 9804ca102..000000000 --- a/packages/react-native/examples/UIKitIntrinsicLabel.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import NativeScript from '@nativescript/react-native'; - -export const UIKitIntrinsicLabel = NativeScript.defineUIKitView< - {text: string}, - UILabel ->({ - name: 'UIKitIntrinsicLabel', - layout: { - sizing: 'intrinsic', - defaultSize: {width: 1, height: 1}, - }, - create() { - return UILabel.new(); - }, - update(label, props, _previous, ctx) { - label.text = props.text; - ctx?.invalidateLayout(); - }, -}); diff --git a/packages/react-native/examples/UIKitPresentation.ts b/packages/react-native/examples/UIKitPresentation.ts index 60bd2e7e8..5330d6b9e 100644 --- a/packages/react-native/examples/UIKitPresentation.ts +++ b/packages/react-native/examples/UIKitPresentation.ts @@ -23,7 +23,7 @@ export function topVisibleViewController( export async function presentDocumentCamera( delegate: VNDocumentCameraViewControllerDelegate, ) { - await NativeScript.runOnUI(() => { + await NativeScript.scheduleOnUI(() => { 'worklet'; if ( !NativeScript.loadFramework('VisionKit') || @@ -48,7 +48,7 @@ export async function presentDocumentCamera( } export async function presentPasses(pass: PKPass) { - await NativeScript.runOnUI(() => { + await NativeScript.scheduleOnUI(() => { 'worklet'; if ( !NativeScript.loadFramework('PassKit') || diff --git a/packages/react-native/examples/UIKitSwitch.tsx b/packages/react-native/examples/UIKitSwitch.tsx deleted file mode 100644 index 3af38d897..000000000 --- a/packages/react-native/examples/UIKitSwitch.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import NativeScript from '@nativescript/react-native'; - -export const UIKitSwitch = NativeScript.defineUIKitView< - { - value: boolean; - onValueChange?: (value: boolean) => void; - }, - UISwitch ->({ - name: 'UIKitSwitch', - layout: {sizing: 'intrinsic'}, - create(ctx) { - const view = UISwitch.new(); - ctx.targetAction(view, UIControlEvents.ValueChanged, () => { - ctx.emit('onValueChange', view.on); - }); - return view; - }, - update(view, props) { - if (view.on !== props.value) { - view.setOnAnimated(props.value, false); - } - }, -}); diff --git a/packages/react-native/examples/UIKitTabBarController.tsx b/packages/react-native/examples/UIKitTabBarController.tsx deleted file mode 100644 index ad678c654..000000000 --- a/packages/react-native/examples/UIKitTabBarController.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import NativeScript from '@nativescript/react-native'; - -export type UIKitTabBarItem = { - title: string; - systemItem?: interop.Enum; -}; - -export const UIKitTabBarControllerHost = NativeScript.defineUIViewController<{ - items: UIKitTabBarItem[]; - selectedIndex?: number; -}>({ - name: 'UIKitTabBarControllerHost', - layout: {sizing: 'fill'}, - createController() { - return UITabBarController.new(); - }, - update(controller, props) { - const children = (props.items ?? []).map((item) => { - const child = UIViewController.new(); - child.view.backgroundColor = UIColor.systemBackgroundColor; - child.tabBarItem = - item.systemItem == null - ? UITabBarItem.alloc().initWithTitleImageTag(item.title, null, 0) - : UITabBarItem.alloc().initWithTabBarSystemItemTag(item.systemItem, 0); - return child; - }); - - controller.viewControllers = NSArray.arrayWithArray(children); - controller.selectedIndex = Math.min( - Math.max(props.selectedIndex ?? 0, 0), - Math.max(children.length - 1, 0), - ); - }, -}); diff --git a/packages/react-native/examples/UIKitViewController.tsx b/packages/react-native/examples/UIKitViewController.tsx deleted file mode 100644 index 90d364f55..000000000 --- a/packages/react-native/examples/UIKitViewController.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import NativeScript from '@nativescript/react-native'; - -export const UIKitViewControllerHost = NativeScript.defineUIViewController<{ - backgroundColor?: UIColor; -}>({ - name: 'UIKitViewControllerHost', - layout: {sizing: 'fill'}, - createController() { - return UIViewController.new(); - }, - update(controller, props) { - controller.view.backgroundColor = - props.backgroundColor ?? UIColor.systemBackgroundColor; - }, -}); diff --git a/packages/react-native/ios/Fabric/NativeScriptComponentDescriptor.h b/packages/react-native/ios/Fabric/NativeScriptComponentDescriptor.h new file mode 100644 index 000000000..babe383cb --- /dev/null +++ b/packages/react-native/ios/Fabric/NativeScriptComponentDescriptor.h @@ -0,0 +1,106 @@ +#pragma once + +// M1 (ARCHITECTURE.md §4.2): the real Fabric types shared by every +// flavor-registered NativeScript component name. Replaces M0's placeholder +// reuse of RN's built-in ViewProps/ViewEventEmitter with: +// +// - NativeScriptProps: extends ViewProps (so standard layout/style props +// keep behaving through Yoga/RN's own diffing) and retains the +// non-view raw props verbatim as `folly::dynamic`; no codegen, no typed +// C++ struct; typing lives entirely in the TS `defineNativeComponent` +// spec. Precedented verbatim by RN's own +// `LegacyViewManagerInteropViewProps` (react-native/ReactCommon/react/ +// renderer/components/legacyviewmanagerinterop/LegacyViewManagerInteropViewProps.h). +// - NativeScriptState: the generic UIKit -> shadow-tree write-back slot +// (`ctx.setContentSize`), same shape as upstream react-native-screens' +// `RNSScreenState` (RNSScreen.mm:147-151). +// - NativeScriptEventEmitter: `ctx.emit` lands here via +// `EventEmitter::dispatchEvent`. Correction to ARCHITECTURE.md §4.2: on +// RN 0.85 `EventEmitter::dispatchEvent` is already `public` (older RN had +// it `protected`, which is what the doc's "exposes dispatchEvent... over +// the protected EventEmitter::dispatchEvent" phrasing assumed); no +// exposing wrapper is needed. Kept as a real (if thin) subclass anyway, +// both to match the design's naming and as a NativeScript-specific +// extension point. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace facebook::react { + +// Placeholder compile-time name baked into the ShadowNode template; the +// Fabric-visible name actually used for registration/lookup comes from +// `flavor_` (see NativeScriptComponentDescriptor::getComponentName below), +// exactly like the LegacyViewManagerInterop precedent this mirrors. +extern const char NativeScriptComponentName[]; + +class NativeScriptProps final : public ViewProps { + public: + NativeScriptProps() = default; + NativeScriptProps(const PropsParserContext& context, + const NativeScriptProps& sourceProps, + const RawProps& rawProps); + + // Every prop the TS spec declared, verbatim, as a folly::dynamic object. + // Delivered to a worklet `updateProps(ctx, next, prev)` hook via + // `jsi::valueFromDynamic` (a real JSI/folly::dynamic bridge, NOT + // JSON.stringify/parse; ARCHITECTURE.md's "no JSON marshalling" rule). + const folly::dynamic rawProps{folly::dynamic::object()}; +}; + +// {contentSize, contentOffsetY, nativeSizeAuthority}; ctx.setContentSize +// writes here; Yoga treats a state-imposed size exactly as RNS's +// `RNSScreenState` does. Deliberately a plain aggregate (no methods): the +// only thing that touches it is `ConcreteState`. +struct NativeScriptState { + Size contentSize{}; + Float contentOffsetY{0}; + bool nativeSizeAuthority{false}; +}; + +class NativeScriptEventEmitter final : public ViewEventEmitter { + public: + using ViewEventEmitter::ViewEventEmitter; +}; + +using NativeScriptShadowNode = + ConcreteViewShadowNode; + +class NativeScriptComponentDescriptor final + : public ConcreteComponentDescriptor { + public: + using ConcreteComponentDescriptor::ConcreteComponentDescriptor; + + // `name`/`handle` are derived from `flavor_` (set by the per-name + // registration in NativeScriptComponentRegistration.mm), not from + // `NativeScriptShadowNode::Name()`. See ComponentDescriptor.h's `Flavor` + // doc comment: "designed to allow registering instances of the exact same + // ComponentDescriptor class with different ComponentName and + // ComponentHandle." + ComponentHandle getComponentHandle() const override; + ComponentName getComponentName() const override; + + // M1 review §2/(d), fix-list item 7: `ctx.setContentSize` used to write + // `NativeScriptState` that nothing consumed; there was no `adopt()` + // override, so the state committed to the shadow tree but never touched + // Yoga. Mirrors `RNSScreenComponentDescriptor::adopt` (react-native-screens + // common/cpp/.../RNSScreenComponentDescriptor.h) minus its Android-only + // orientation-commit-hook machinery: when the state carries a non-zero + // size AND the author asked for size authority (`ctx.setContentSize(..., + // {authority: true})`, the default), that size is applied straight to the + // Yoga node, so `onLayout` observes it. + void adopt(ShadowNode& shadowNode) const override; +}; + +} // namespace facebook::react diff --git a/packages/react-native/ios/Fabric/NativeScriptComponentDescriptor.mm b/packages/react-native/ios/Fabric/NativeScriptComponentDescriptor.mm new file mode 100644 index 000000000..a06eaa7dc --- /dev/null +++ b/packages/react-native/ios/Fabric/NativeScriptComponentDescriptor.mm @@ -0,0 +1,41 @@ +#include "NativeScriptComponentDescriptor.h" + +namespace facebook::react { + +extern const char NativeScriptComponentName[] = "NativeScriptComponent"; + +NativeScriptProps::NativeScriptProps(const PropsParserContext& context, + const NativeScriptProps& sourceProps, + const RawProps& rawProps) + : ViewProps(context, sourceProps, rawProps), rawProps(rawProps.toDynamic()) {} + +ComponentHandle NativeScriptComponentDescriptor::getComponentHandle() const { + return reinterpret_cast(getComponentName()); +} + +ComponentName NativeScriptComponentDescriptor::getComponentName() const { + if (flavor_ == nullptr) { + // No flavor: fall back to the generic compile-time name. In practice + // every NativeScript-registered name goes through + // NativeScriptRegisterFlavoredComponent, which always sets a flavor. + return NativeScriptShadowNode::Name(); + } + return static_cast(flavor_.get())->c_str(); +} + +void NativeScriptComponentDescriptor::adopt(ShadowNode& shadowNode) const { + auto& layoutableShadowNode = static_cast(shadowNode); + + auto state = std::static_pointer_cast(shadowNode.getState()); + if (state != nullptr) { + const NativeScriptState& stateData = state->getData(); + if (stateData.nativeSizeAuthority && stateData.contentSize.width != 0 && + stateData.contentSize.height != 0) { + layoutableShadowNode.setSize(Size{stateData.contentSize.width, stateData.contentSize.height}); + } + } + + ConcreteComponentDescriptor::adopt(shadowNode); +} + +} // namespace facebook::react diff --git a/packages/react-native/ios/Fabric/NativeScriptComponentRegistration.h b/packages/react-native/ios/Fabric/NativeScriptComponentRegistration.h new file mode 100644 index 000000000..c90de0f5a --- /dev/null +++ b/packages/react-native/ios/Fabric/NativeScriptComponentRegistration.h @@ -0,0 +1,44 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +// Registers `name` as a Fabric component that resolves to a fresh, per-name +// dynamic subclass of NativeScriptComponentView, via the PUBLIC RN API +// (`+componentDescriptorProvider` + `registerComponentViewClass:` on +// RCTComponentViewFactory); no private ivars, no `_providerRegistry` +// reach-around (ARCHITECTURE.md §4.1). Idempotent: calling twice with the +// same name is a no-op except for updating the stored hook mask. +// +// Called from `defineNativeComponent(name, spec)`'s native registration step +// (ARCHITECTURE.md §5.2 step 2) via NativeScriptNativeApiModule::registerComponent. +// `hookMask` is a bitwise-OR of NativeScriptComponentHook values +// (NativeScriptFabricGateway.h); which optional Fabric callbacks this +// definition actually declared, stored on the per-flavor dynamic Class so +// every instance can read it back without a lookup (the same trick +// RCTComponentViewFactory itself uses to decide +// `observesMountingTransactionWillMount` per class). +// +// M1 review §2/(c), fix-list item 3: `hasShouldBeRecycled`/`shouldBeRecycled` +// wire the spec's `shouldBeRecycled` flag onto a per-flavor `+(BOOL) +// shouldBeRecycled` class method via `class_addMethod`/`class_replaceMethod` +// on the dynamic subclass's metaclass; the SAME per-flavor-dynamic-class +// trick `+componentDescriptorProvider` below already uses, applied to the +// selector `RCTComponentViewFactory` itself probes (optionally, via +// `class_respondsToSelector`) to decide whether a view goes through +// `-invalidate` (never recycled) or the default recycle pool. Omitted +// (`hasShouldBeRecycled == NO`) when the spec never set the flag, leaving +// RN's own default (`shouldBeRecycled: true`, RCTComponentViewClassDescriptor.h) +// in effect. +FOUNDATION_EXPORT void NativeScriptRegisterFlavoredComponent(NSString* name, uint32_t hookMask, + BOOL hasShouldBeRecycled, + BOOL shouldBeRecycled); + +// Stable associated-object keys (function-local static addresses, so they +// are guaranteed identical across translation units) under which the +// registered Fabric component name / hook mask are stored on each +// per-flavor dynamic Class, so instances can read both back without parsing +// them out of the (otherwise-arbitrary) dynamic class name. +FOUNDATION_EXPORT const void* NativeScriptFlavorNameAssociationKey(void); +FOUNDATION_EXPORT const void* NativeScriptFlavorHookMaskAssociationKey(void); + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native/ios/Fabric/NativeScriptComponentRegistration.mm b/packages/react-native/ios/Fabric/NativeScriptComponentRegistration.mm new file mode 100644 index 000000000..a5ecb578e --- /dev/null +++ b/packages/react-native/ios/Fabric/NativeScriptComponentRegistration.mm @@ -0,0 +1,138 @@ +#import "NativeScriptComponentRegistration.h" + +#import + +#import +#import + +#include +#include +#include + +#include "NativeScriptComponentDescriptor.h" +#include "NativeScriptComponentView.h" + +using namespace facebook::react; + +namespace { + +std::mutex& NativeScriptRegistrationMutex() { + static std::mutex mutex; + return mutex; +} + +NSString* NativeScriptDynamicClassName(NSString* name) { + return [@"NativeScriptComponentView_Flavor_" stringByAppendingString:name]; +} + +} // namespace + +const void* NativeScriptFlavorNameAssociationKey(void) { + static int key; + return &key; +} + +const void* NativeScriptFlavorHookMaskAssociationKey(void) { + static int key; + return &key; +} + +void NativeScriptRegisterFlavoredComponent(NSString* name, uint32_t hookMask, BOOL hasShouldBeRecycled, + BOOL shouldBeRecycled) { + if (name.length == 0) { + return; + } + + std::lock_guard lock(NativeScriptRegistrationMutex()); + + NSString* dynClassName = NativeScriptDynamicClassName(name); + Class dynClass = NSClassFromString(dynClassName); + + if (dynClass == Nil) { + // A per-name, otherwise-empty subclass of the ONE generic ComponentView + // (ARCHITECTURE.md §4.1 step 1). It adds no ivars/methods of its own + // except the class-side `+componentDescriptorProvider` override below -- + // every instance behaves exactly like NativeScriptComponentView. + dynClass = objc_allocateClassPair(NativeScriptComponentView.class, dynClassName.UTF8String, 0); + if (dynClass == Nil) { + NSLog(@"NativeScript: failed to allocate flavored component class for %@", name); + return; + } + + // `flavor` is retained by the ComponentDescriptorProvider's shared_ptr, + // not by the block; capture a std::string copy, not the NSString. + auto flavorName = std::make_shared(name.UTF8String != nullptr ? name.UTF8String : ""); + + // Reuse the base class's constructor (the actual C++ NativeScriptComponentDescriptor + // template instantiation is shared across every flavor; only name/handle/flavor differ). + ComponentDescriptorConstructor* sharedConstructor = + [NativeScriptComponentView componentDescriptorProvider].constructor; + + ComponentDescriptorProvider (^providerBlock)(id) = ^ComponentDescriptorProvider(id self) { + ComponentName componentName = flavorName->c_str(); + ComponentHandle componentHandle = reinterpret_cast(componentName); + return ComponentDescriptorProvider{ + .handle = componentHandle, + .name = componentName, + .flavor = flavorName, + .constructor = sharedConstructor, + }; + }; + + // `imp_implementationWithBlock` builds a real, ABI-correct trampoline for + // the block's signature (it does not need the type-encoding string to be + // byte-accurate for ordinary objc_msgSend dispatch; that string is only + // consulted by introspection APIs, not by a compile-time-typed message + // send like `[componentViewClass componentDescriptorProvider]`, which is + // exactly how RCTComponentViewFactory calls it). This sidesteps hand + // writing a raw C IMP with the correct large-non-POD-struct return ABI. + IMP providerImp = imp_implementationWithBlock(providerBlock); + Class metaClass = object_getClass(dynClass); + // The type-encoding string below is NOT byte-accurate (ComponentDescriptorProvider + // is a non-POD C++ type; @encode has no notion of it) and does not need + // to be: objc_msgSend at RCTComponentViewFactory's call site dispatches + // using the return type it knows statically from RCTComponentViewProtocol's + // declared `+(ComponentDescriptorProvider)componentDescriptorProvider`, not + // from this string (that string is only consulted by introspection APIs -- + // NSInvocation/KVO/-methodSignatureForSelector:; none of which + // registerComponentViewClass: uses). imp_implementationWithBlock builds a + // real ABI-correct trampoline from the block's own (compiler-checked) + // signature, which is what actually makes the struct return work. + class_addMethod(metaClass, @selector(componentDescriptorProvider), providerImp, "{ComponentDescriptorProvider=}@:"); + + objc_registerClassPair(dynClass); + + // Store the ACTUAL registered Fabric name on the class itself, so + // instances need not parse it back out of the dynamic class name. + objc_setAssociatedObject((id)dynClass, NativeScriptFlavorNameAssociationKey(), name, + OBJC_ASSOCIATION_RETAIN); + } + + // Hook mask can legitimately change across `defineNativeComponent` reload + // re-invocations (fast refresh editing a spec's hook set); always + // refresh it, even when the class itself already existed. + objc_setAssociatedObject((id)dynClass, NativeScriptFlavorHookMaskAssociationKey(), @(hookMask), + OBJC_ASSOCIATION_RETAIN); + + // M1 review §2/(c): `+shouldBeRecycled`, per flavor, class_replaceMethod'd + // onto the metaclass (idempotent across re-registration, unlike + // class_addMethod); the same trick as `+componentDescriptorProvider` + // above. RCTComponentViewFactory reads this OPTIONAL class method (it is + // not part of RCTComponentViewProtocol's required set) to decide whether + // RCTComponentViewRegistry recycles a torn-down view (default, when this + // method is absent) or calls `-invalidate` instead (see + // NativeScriptComponentView.mm's `-invalidate` override for the matching + // dispose-path fix). + if (hasShouldBeRecycled) { + Class metaClass = object_getClass(dynClass); + BOOL recycledValue = shouldBeRecycled; + BOOL (^shouldBeRecycledBlock)(id) = ^BOOL(id self) { + return recycledValue; + }; + class_replaceMethod(metaClass, @selector(shouldBeRecycled), imp_implementationWithBlock(shouldBeRecycledBlock), + "c@:"); + } + + [[RCTComponentViewFactory currentComponentViewFactory] + registerComponentViewClass:(Class)dynClass]; +} diff --git a/packages/react-native/ios/Fabric/NativeScriptComponentView.h b/packages/react-native/ios/Fabric/NativeScriptComponentView.h new file mode 100644 index 000000000..9affa60ad --- /dev/null +++ b/packages/react-native/ios/Fabric/NativeScriptComponentView.h @@ -0,0 +1,45 @@ +#import +#import + +#include +#include + +#include + +NS_ASSUME_NONNULL_BEGIN + +// M1 (ARCHITECTURE.md §4.3): the ONE generic ComponentView shared by every +// flavored NativeScript component name. Per Fabric callback, the rule is: +// default behavior in ObjC; forward to the TS instance same-thread (via +// NativeScriptFabricGateway) only if the definition declared that hook (a +// bitmask captured at `defineNativeComponent`/registration time, read here +// off the per-flavor dynamic class's associated object; the same trick +// RCTComponentViewFactory itself uses to decide +// `observesMountingTransactionWillMount` per class). +@interface NativeScriptComponentView : RCTViewComponentView + +// Called by the `__nativeScriptComponentEmit` / `__nativeScriptComponentSetContentSize` +// host functions (NativeScriptInstallComponentHostFunctions below), which +// receive `self` unwrapped from the wrapped `ctx.view` a worklet was handed +// at `create`; real method calls on a live object reached via +// NativeScriptUnwrapNativeObject, not a string-keyed RPC. `dispatchEvent` +// forwards straight to the Fabric `EventEmitter` (§4.4); `setContentSize` +// forwards to the Fabric `State` write-back slot (§4.2's NativeScriptState). +- (void)nativeScriptDispatchEventName:(const std::string&)name payload:(folly::dynamic&&)payload; +- (void)nativeScriptSetContentSizeWidth:(double)width + height:(double)height + offsetY:(double)offsetY + authority:(BOOL)authority; + +@end + +// Installs the three ctx-support host functions +// (`__nativeScriptComponentEmit`/`__nativeScriptComponentSetContentSize`/ +// `__nativeScriptComponentScheduleOnMainQueue`) that `src/ui/dispatcher.ts`'s +// `ctx.emit`/`ctx.setContentSize`/`ctx.scheduleOnMainQueue` call into. MUST +// be called from inside a `runSync` on the UI runtime (installUIRuntime's +// materialization block); idempotent (a no-op if already installed on this +// runtime instance). +void NativeScriptInstallComponentHostFunctions(facebook::jsi::Runtime& runtime); + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native/ios/Fabric/NativeScriptComponentView.mm b/packages/react-native/ios/Fabric/NativeScriptComponentView.mm new file mode 100644 index 000000000..3359ee4b2 --- /dev/null +++ b/packages/react-native/ios/Fabric/NativeScriptComponentView.mm @@ -0,0 +1,714 @@ +#import "NativeScriptComponentView.h" + +#import +#import + +#import +#import + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "NativeApiJsi.h" +#include "NativeScriptComponentDescriptor.h" +#import "NativeScriptComponentRegistration.h" +#include "NativeScriptFabricGateway.h" + +using namespace facebook::react; +using namespace nativescript; +namespace jsi = facebook::jsi; + +namespace { + +// Reads the hook mask + registered Fabric name stashed on this instance's +// class by NativeScriptRegisterFlavoredComponent; one associated-object +// read, not a lookup keyed by anything string-parsed. +uint32_t NativeScriptHookMaskForClass(Class cls) { + NSNumber* stored = objc_getAssociatedObject(cls, NativeScriptFlavorHookMaskAssociationKey()); + return stored != nil ? (uint32_t)stored.unsignedIntegerValue : 0; +} + +// M1 review §1/#2: mounting-transaction hooks used to be dispatched with +// ZERO arguments; native pre-filtered to Insert|Remove mutations whose +// `parentTag == self.tag` and threw the transaction itself away. Two real +// consequences: (a) upstream's willMount use case (RNSScreenStack.mm:1338- +// 1347, `willBeUnmountedInUpcomingTransaction`) scans **Delete** mutations, +// which never carry a meaningful `parentTag` (ShadowViewMutation.h: only +// `InsertMutation`/`RemoveMutation` take one; `DeleteMutation` does not) and +// so never matched the old filter; that use case could not fire at all; +// (b) even when a hook DID fire, TS had no way to know *which* child. Fix: +// forward every Insert/Remove/Delete mutation in the transaction as a plain +// {type, tag, parentTag, index} array; `tag` is the inserted/removed child +// for Insert/Remove, the deleted node for Delete (RNS's own +// `oldChildShadowView.tag` read). `dispatcher.ts` wraps this into the +// `txn.didMutateChildrenOf(tag)` shape ARCHITECTURE.md §6's worked example +// already calls. Relevance gating (skip the runSync entirely when nothing +// could matter to ANY hook) is still done natively for cost, per hook: +// mountingTransactionDidMount only ever needs Insert/Remove with a matching +// parentTag (RNS's own `didMount` filter, unchanged); willMount cannot be +// parentTag-gated (Delete's parentTag isn't populated) so it fires whenever +// the transaction contains any relevant mutation at all; TS decides real +// relevance from the payload, exactly as RNSScreenStack.mm's own willMount +// scans everything and discards what its `childScreenForTag` lookup misses. +jsi::Array NativeScriptBuildMutationsArray(jsi::Runtime& rt, + const facebook::react::MountingTransaction& transaction) { + const auto& mutations = transaction.getMutations(); + std::vector relevant; + for (size_t i = 0; i < mutations.size(); i++) { + auto type = mutations[i].type; + if (type == facebook::react::ShadowViewMutation::Insert || + type == facebook::react::ShadowViewMutation::Remove || + type == facebook::react::ShadowViewMutation::Delete) { + relevant.push_back(i); + } + } + jsi::Array array(rt, relevant.size()); + for (size_t i = 0; i < relevant.size(); i++) { + const auto& mutation = mutations[relevant[i]]; + const char* typeName = mutation.type == facebook::react::ShadowViewMutation::Insert ? "insert" + : mutation.type == facebook::react::ShadowViewMutation::Remove ? "remove" + : "delete"; + facebook::react::Tag tag = mutation.type == facebook::react::ShadowViewMutation::Insert + ? mutation.newChildShadowView.tag + : mutation.oldChildShadowView.tag; + jsi::Object object(rt); + object.setProperty(rt, "type", jsi::String::createFromUtf8(rt, typeName)); + object.setProperty(rt, "tag", (double)tag); + object.setProperty(rt, "parentTag", (double)mutation.parentTag); + object.setProperty(rt, "index", (double)mutation.index); + array.setValueAtIndex(rt, i, object); + } + return array; +} + +bool NativeScriptTransactionHasChildMutation(const facebook::react::MountingTransaction& transaction, + facebook::react::Tag myTag) { + for (const auto& mutation : transaction.getMutations()) { + if (mutation.parentTag == myTag && + (mutation.type == facebook::react::ShadowViewMutation::Insert || + mutation.type == facebook::react::ShadowViewMutation::Remove)) { + return true; + } + } + return false; +} + +// M1 review §5/#5: intentional, tiny, documented leak; the alternative is +// destructing a `jsi::Function` (whose destructor talks back to the Runtime +// that created it) against a Runtime that a reload may have already torn +// down, which is a use-after-free, not a hypothetical one (this is exactly +// the crash class NativeScriptFabricGateway.mm's own comment on the +// deleted `static std::shared_ptr` cache describes). Held forever +// in a process-lifetime vector rather than freed at an unsafe moment; this +// only happens on the (rare, dev-reload-only) generation-mismatch path, not +// on every scheduleOnMainQueue call. +void NativeScriptLeakScheduledCallback(std::shared_ptr callback) { + static std::mutex mutex; + static std::vector>* leaked = new std::vector>(); + std::lock_guard lock(mutex); + leaked->push_back(std::move(callback)); +} + +bool NativeScriptTransactionHasAnyRelevantMutation(const facebook::react::MountingTransaction& transaction, + facebook::react::Tag myTag) { + if (NativeScriptTransactionHasChildMutation(transaction, myTag)) { + return true; + } + for (const auto& mutation : transaction.getMutations()) { + if (mutation.type == facebook::react::ShadowViewMutation::Delete) { + return true; + } + } + return false; +} + +} // namespace + +typedef jsi::Value (^NativeScriptArgBuilder)(jsi::Runtime& rt); + +@implementation NativeScriptComponentView { + BOOL _nsCreated; + facebook::react::State::Shared _nsState; + // M1 review §2/(d)/§5/#2 verification finding: `ctx.setContentSize` + // called from `create()`; same bottom-up-mounting ordering hazard as + // `_nsPendingEvents` above (`-updateProps:` and its `create()` call can + // run before `-updateState:oldState:` ever has); was being silently + // dropped: `nativeScriptSetContentSizeWidth:...` bailed on a null + // `_nsState` with nothing buffered, so the FIRST (often only) call an + // author makes from `create()` never reached Yoga even after `adopt()` + // was implemented. Buffered here, applied the moment -updateState: + // provides a real state pointer; proven on-sim: without this, `adopt()` + // alone was not sufficient (0 layouts observed for the requested size). + bool _nsHasPendingContentSize; + NativeScriptState _nsPendingContentSize; + // ctx.emit calls made before `_eventEmitter` exists (see the comment on + // -nsEnsureCreated below for why that can happen) are buffered here and + // flushed the moment -updateEventEmitter: makes one available; never + // silently dropped. + std::vector> _nsPendingEvents; +} + +- (instancetype)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + static const auto defaultProps = std::make_shared(); + _props = defaultProps; + } + return self; +} + +#pragma mark - Identity helpers + +- (NSString*)nsComponentName { + NSString* registered = objc_getAssociatedObject(self.class, NativeScriptFlavorNameAssociationKey()); + return registered != nil ? registered : NSStringFromClass(self.class); +} + +- (BOOL)nsHasHook:(NativeScriptComponentHook)hook { + return (NativeScriptHookMaskForClass(self.class) & (uint32_t)hook) != 0; +} + +#pragma mark - Gateway dispatch + +// Shared low-level entry point: wraps `self` as `ctx.view`, builds up to +// three additional args (a/b/c) via the supplied blocks (called INSIDE the +// runSync, so they may safely construct jsi::Values), and forwards to +// NativeScriptFabricGatewayDispatchComponentHook. The jsi::Value result is +// deliberately never returned to the ObjC caller; a JSI Value must not be +// touched once its runSync lock is released; callers that need the result +// use nsDispatchCreateHook/nsDispatchLayoutHook below, which interpret the +// result INSIDE the lambda and return a plain (POD) ObjC/C++ value instead. +- (void)nsDispatchHook:(NSString*)hookName + a:(nullable NativeScriptArgBuilder)aBuilder + b:(nullable NativeScriptArgBuilder)bBuilder + c:(nullable NativeScriptArgBuilder)cBuilder { + std::string flavorName = self.nsComponentName.UTF8String ?: ""; + std::string hookNameStd = hookName.UTF8String ?: ""; + double tag = (double)self.tag; + NativeScriptComponentView* __unsafe_unretained weakSelf = self; + nativescript::NativeScriptFabricGatewayRunSyncOnMain( + [flavorName, hookNameStd, tag, weakSelf, aBuilder, bBuilder, cBuilder](jsi::Runtime& rt) -> bool { + jsi::Value viewValue = weakSelf != nil + ? nativescript::NativeScriptWrapNativeObject(rt, (__bridge void*)weakSelf) + : jsi::Value::null(); + jsi::Value a = aBuilder != nil ? aBuilder(rt) : jsi::Value::undefined(); + jsi::Value b = bBuilder != nil ? bBuilder(rt) : jsi::Value::undefined(); + jsi::Value c = cBuilder != nil ? cBuilder(rt) : jsi::Value::undefined(); + nativescript::NativeScriptFabricGatewayDispatchComponentHook(rt, flavorName, tag, hookNameStd, + viewValue, a, b, c); + return true; + }); +} + +// `create` is unconditional (every `defineNativeComponent` spec provides +// it, per the worked example; ARCHITECTURE.md §6) and lazy: it runs on +// the FIRST Fabric lifecycle call this instance receives; called +// defensively at the top of every hook below, not just -updateProps: -- +// rather than eagerly in `-initWithFrame:` (ARCHITECTURE.md §8.10's "eager +// attach" cost). "First call" is deliberately not assumed to be +// -updateProps: specifically: RCTMountingManager.mm inserts children +// bottom-up (a child's full Insert lifecycle, ending in +// `[parent mountChildComponentView:child]`, runs before the PARENT's own +// Insert lifecycle starts), so a container can see +// -mountChildComponentView: fire before its own -updateProps:/ +// -updateEventEmitter: ever have. `_eventEmitter` may therefore still be +// null when `create`'s `ctx.emit` calls run; nativeScriptDispatchEventName: +// payload: buffers them; -updateEventEmitter: flushes the buffer the +// moment a real emitter exists. If the hook returns a wrapped UIView, it is +// installed as `contentView`. +- (void)nsEnsureCreated { + if (_nsCreated) { + return; + } + + std::string flavorName = self.nsComponentName.UTF8String ?: ""; + double tag = (double)self.tag; + NativeScriptComponentView* __unsafe_unretained weakSelf = self; + bool ran = false; + void* contentViewPtr = nativescript::NativeScriptFabricGatewayRunSyncOnMain( + [flavorName, tag, weakSelf](jsi::Runtime& rt) -> void* { + jsi::Value viewValue = weakSelf != nil + ? nativescript::NativeScriptWrapNativeObject(rt, (__bridge void*)weakSelf) + : jsi::Value::null(); + jsi::Value undef = jsi::Value::undefined(); + jsi::Value result = nativescript::NativeScriptFabricGatewayDispatchComponentHook( + rt, flavorName, tag, "create", viewValue, undef, undef, undef); + if (!result.isObject()) { + return nullptr; + } + return nativescript::NativeScriptUnwrapNativeObject(rt, result); + }, + &ran); + + if (!ran) { + // M1 review §4/(i): the gateway found no live UI runtime (e.g. `create` + // requested before installUIRuntime() has run, or during the dead + // window of a reload); do NOT latch `_nsCreated`, or this view is + // permanently, silently dead: every later hook's own `nsEnsureCreated` + // defensive call would see YES and skip forever. Leaving it NO means the + // very next hook dispatch (updateProps/mountChild/etc., all of which + // call this defensively) retries. + return; + } + _nsCreated = YES; + + if (contentViewPtr != nullptr) { + id maybeView = (__bridge id)contentViewPtr; + if ([maybeView isKindOfClass:UIView.class]) { + self.contentView = (UIView*)maybeView; + } + } +} + +- (BOOL)nsDispatchLayoutHook:(const facebook::react::LayoutMetrics&)next + old:(const facebook::react::LayoutMetrics&)prev { + std::string flavorName = self.nsComponentName.UTF8String ?: ""; + double tag = (double)self.tag; + facebook::react::Rect nextFrame = next.frame; + facebook::react::Rect prevFrame = prev.frame; + NativeScriptComponentView* __unsafe_unretained weakSelf = self; + bool ran = false; + bool accept = nativescript::NativeScriptFabricGatewayRunSyncOnMain( + [flavorName, tag, weakSelf, nextFrame, prevFrame](jsi::Runtime& rt) -> bool { + jsi::Value viewValue = weakSelf != nil + ? nativescript::NativeScriptWrapNativeObject(rt, (__bridge void*)weakSelf) + : jsi::Value::null(); + auto frameObject = [&rt](const facebook::react::Rect& frame) -> jsi::Value { + jsi::Object object(rt); + object.setProperty(rt, "x", (double)frame.origin.x); + object.setProperty(rt, "y", (double)frame.origin.y); + object.setProperty(rt, "width", (double)frame.size.width); + object.setProperty(rt, "height", (double)frame.size.height); + return jsi::Value(rt, object); + }; + jsi::Value undef = jsi::Value::undefined(); + jsi::Value result = nativescript::NativeScriptFabricGatewayDispatchComponentHook( + rt, flavorName, tag, "updateLayoutMetrics", viewValue, frameObject(nextFrame), + frameObject(prevFrame), undef); + return !result.isBool() || result.getBool(); + }, + &ran); + return ran ? (accept ? YES : NO) : YES; +} + +#pragma mark - RCTComponentViewProtocol + ++ (ComponentDescriptorProvider)componentDescriptorProvider { + // Generic/unflavored provider for the base class itself (never rendered + // directly by JS; only the per-name dynamic subclasses created by + // NativeScriptRegisterFlavoredComponent are). Registering the base class + // is still useful: it is exactly the `constructor` every flavored + // subclass's provider reuses. + return concreteComponentDescriptorProvider(); +} + +- (void)nsForwardUpdateProps:(const std::shared_ptr&)nextProps + old:(const std::shared_ptr&)prevProps { + if (![self nsHasHook:NativeScriptComponentHookUpdateProps] || nextProps == nullptr) { + return; + } + folly::dynamic nextRaw = nextProps->rawProps; + folly::dynamic prevRaw = prevProps != nullptr ? prevProps->rawProps : folly::dynamic::object(); + [self nsDispatchHook:@"updateProps" + a:^jsi::Value(jsi::Runtime& rt) { + return jsi::valueFromDynamic(rt, nextRaw); + } + b:^jsi::Value(jsi::Runtime& rt) { + return jsi::valueFromDynamic(rt, prevRaw); + } + c:nil]; +} + +- (void)updateProps:(const Props::Shared&)props oldProps:(const Props::Shared&)oldProps { + [super updateProps:props oldProps:oldProps]; + [self nsEnsureCreated]; + auto nextProps = std::static_pointer_cast(props); + if (nextProps == nullptr) { + return; + } + [self nsForwardUpdateProps:nextProps old:std::static_pointer_cast(oldProps)]; +} + +- (void)updateEventEmitter:(const facebook::react::EventEmitter::Shared&)eventEmitter { + // NS_REQUIRES_SUPER on RCTViewComponentView; the base implementation + // stores `_eventEmitter`. + [super updateEventEmitter:eventEmitter]; + [self nsEnsureCreated]; // idempotent; a no-op on the (common) path where -updateProps: already ran first. + [self nsFlushPendingEvents]; +} + +- (void)updateState:(const facebook::react::State::Shared&)state + oldState:(const facebook::react::State::Shared&)oldState { + // Not NS_REQUIRES_SUPER on RCTViewComponentView (the base UIView category + // implementation is a no-op); we own storing `_nsState` entirely so + // `ctx.setContentSize` has something to write back into (§4.2). + _nsState = state; + if (_nsHasPendingContentSize) { + _nsHasPendingContentSize = false; + auto concreteState = + std::static_pointer_cast>(_nsState); + if (concreteState != nullptr) { + concreteState->updateState(NativeScriptState{_nsPendingContentSize}); + } + } +} + +// M1 review §1/#1 (the crash fix): mount/unmount are notifications wrapped +// around an UNCONDITIONAL `[super ...]` in Fabric authoring's own contract +// the author DECIDES whether to call super, and RNSScreenStack.mm:1283-1302 +// decides NO (array-insert only, a screen is never a plain subview). Forcing +// `[super mountChildComponentView:...]` regardless made every screen a real +// subview at mount, so the FIRST time UIKit reparented that view (a push), +// Fabric's default `unmountChildComponentView:` tripped +// `RCTAssert(superview == currentContainerView)`; a guaranteed debug crash. +// Fix: when a definition declares this hook, it OWNS mounting entirely -- +// `super` is never called, matching RNS's own override exactly (no +// return-value protocol needed; declaring the hook IS the decline). No hook +// declared ⇒ unchanged default behavior. +- (void)mountChildComponentView:(UIView*)childComponentView index:(NSInteger)index { + [self nsEnsureCreated]; + if (![self nsHasHook:NativeScriptComponentHookMountChild]) { + [super mountChildComponentView:childComponentView index:index]; + return; + } + double childTag = (double)childComponentView.tag; + double indexValue = (double)index; + // Wrap the actual child view regardless of its concrete class; a + // NativeScript-defined component's `mountChildComponentView` hook may + // receive a plain RN-native child too (§5.1: "child in mount/unmount is + // `{ tag, view, instance? }`; view by reference... instance present when + // the child is NS-defined"). dispatcher.ts resolves `instance` itself via + // its own tag-keyed table; it is not native's job to pre-filter by class. + UIView* __unsafe_unretained weakChild = childComponentView; + [self nsDispatchHook:@"mountChildComponentView" + a:^jsi::Value(jsi::Runtime& rt) { + return nativescript::NativeScriptWrapNativeObject(rt, (__bridge void*)weakChild); + } + b:^jsi::Value(jsi::Runtime& rt) { + return jsi::Value(childTag); + } + c:^jsi::Value(jsi::Runtime& rt) { + return jsi::Value(indexValue); + }]; +} + +- (void)unmountChildComponentView:(UIView*)childComponentView index:(NSInteger)index { + [self nsEnsureCreated]; + if (![self nsHasHook:NativeScriptComponentHookUnmountChild]) { + [super unmountChildComponentView:childComponentView index:index]; + return; + } + double childTag = (double)childComponentView.tag; + double indexValue = (double)index; + UIView* __unsafe_unretained weakChild = childComponentView; + [self nsDispatchHook:@"unmountChildComponentView" + a:^jsi::Value(jsi::Runtime& rt) { + return nativescript::NativeScriptWrapNativeObject(rt, (__bridge void*)weakChild); + } + b:^jsi::Value(jsi::Runtime& rt) { + return jsi::Value(childTag); + } + c:^jsi::Value(jsi::Runtime& rt) { + return jsi::Value(indexValue); + }]; + // Deliberately NOT calling `[super unmountChildComponentView:...]` -- + // symmetric with the mount side above: a definition that owns mounting + // owns unmounting too, so Fabric's default (which asserts the child's + // `superview` still matches `currentContainerView`) never runs against a + // view a hook chose not to install as a plain subview in the first place. +} + +- (void)mountingTransactionWillMount:(const facebook::react::MountingTransaction&)transaction + withSurfaceTelemetry:(const facebook::react::SurfaceTelemetry&)surfaceTelemetry { + [self nsEnsureCreated]; + if (![self nsHasHook:NativeScriptComponentHookWillMount]) { + return; + } + facebook::react::Tag myTag = (facebook::react::Tag)self.tag; + if (!NativeScriptTransactionHasAnyRelevantMutation(transaction, myTag)) { + return; + } + const facebook::react::MountingTransaction* transactionPtr = &transaction; + [self nsDispatchHook:@"mountingTransactionWillMount" + a:^jsi::Value(jsi::Runtime& rt) { + return jsi::Value(rt, NativeScriptBuildMutationsArray(rt, *transactionPtr)); + } + b:nil + c:nil]; +} + +- (void)mountingTransactionDidMount:(const facebook::react::MountingTransaction&)transaction + withSurfaceTelemetry:(const facebook::react::SurfaceTelemetry&)surfaceTelemetry { + [self nsEnsureCreated]; + if (![self nsHasHook:NativeScriptComponentHookDidMount]) { + return; + } + facebook::react::Tag myTag = (facebook::react::Tag)self.tag; + if (!NativeScriptTransactionHasChildMutation(transaction, myTag)) { + return; + } + const facebook::react::MountingTransaction* transactionPtr = &transaction; + [self nsDispatchHook:@"mountingTransactionDidMount" + a:^jsi::Value(jsi::Runtime& rt) { + return jsi::Value(rt, NativeScriptBuildMutationsArray(rt, *transactionPtr)); + } + b:nil + c:nil]; +} + +- (void)updateLayoutMetrics:(const facebook::react::LayoutMetrics&)layoutMetrics + oldLayoutMetrics:(const facebook::react::LayoutMetrics&)oldLayoutMetrics { + [self nsEnsureCreated]; + BOOL accept = YES; + if ([self nsHasHook:NativeScriptComponentHookUpdateLayoutMetrics]) { + accept = [self nsDispatchLayoutHook:layoutMetrics old:oldLayoutMetrics]; + } + if (accept) { + [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics]; + } + // Declining (RNSScreen.mm:1348-1371's pattern): UIKit already owns the + // frame, so `_layoutMetrics` intentionally does not track Fabric's + // proposal here; the same trade upstream makes. +} + +- (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask { + [super finalizeUpdates:updateMask]; + [self nsEnsureCreated]; + if (![self nsHasHook:NativeScriptComponentHookFinalizeUpdates]) { + return; + } + double maskValue = (double)updateMask; + [self nsDispatchHook:@"finalizeUpdates" + a:^jsi::Value(jsi::Runtime& rt) { + return jsi::Value(maskValue); + } + b:nil + c:nil]; +} + +- (void)handleCommand:(NSString*)commandName args:(NSArray*)args { + [self nsEnsureCreated]; + if (![self nsHasHook:NativeScriptComponentHookCommands]) { + return; + } + [self nsDispatchHook:@"handleCommand" + a:^jsi::Value(jsi::Runtime& rt) { + return jsi::Value(rt, jsi::String::createFromUtf8(rt, commandName.UTF8String ?: "")); + } + b:^jsi::Value(jsi::Runtime& rt) { + return facebook::react::TurboModuleConvertUtils::convertObjCObjectToJSIValue(rt, args); + } + c:nil]; +} + +// M1 review §2/(c): a definition registered with `shouldBeRecycled: false` +// (RNSScreen.mm:1193-1196's own default) is torn down through +// `RCTComponentViewRegistry`'s OTHER path; `-invalidate`, never +// `-prepareForRecycle`; so the dispose logic must run from both, or every +// non-recycled component (exactly the ones that matter, like a screen) leaks +// its UI-runtime instance-table entry and its retained `ctx.view` wrapper, +// and silently never calls the author's dispose hook. +// `viaInvalidate`: forwarded to the `prepareForRecycle` hook as its second +// argument so a spec (and this M1.5 verification pass) can tell which +// teardown path actually ran; real, useful information for an author +// (RNS cares about exactly this distinction), and how #4 is proven on-sim. +- (void)nsDisposeViaInvalidate:(BOOL)viaInvalidate { + // Always fires (not hookMask-gated): dispatcher.ts's instance table + // (tag -> {ctx, instance}) must drop this tag regardless of whether the + // spec declared a `prepareForRecycle` hook, or the UI-runtime-side entry + // leaks forever. + if (_nsCreated) { + [self nsDispatchHook:@"prepareForRecycle" + a:^jsi::Value(jsi::Runtime& rt) { + return jsi::Value(viaInvalidate == YES); + } + b:nil + c:nil]; + // Verification-round finding: a `ctx.emit` call made FROM INSIDE the + // dispose hook itself lands in `_nsPendingEvents` (via + // -nativeScriptDispatchEventName:payload:'s existing null-emitter + // buffering) exactly as often as a `create()`-time emit does; flush + // it here, BEFORE the unconditional clear below, while `_eventEmitter` + // is still whatever it was at dispose time. Without this, the clear + // immediately below silently discarded every event a `prepareForRecycle` + // hook ever emitted (on-sim: `InvalidateProbe`'s own `onDisposed`). + [self nsFlushPendingEvents]; + } + _nsCreated = NO; + _nsState = nullptr; + // M1 review §4: a buffered ctx.emit call (see nativeScriptDispatchEventName: + // payload:'s comment) left in `_nsPendingEvents` at teardown must not + // survive into the NEXT tag that reuses this pooled instance; clear it + // here rather than only ever draining it from -updateEventEmitter:. + _nsPendingEvents.clear(); + // Same reasoning for a buffered ctx.setContentSize; see the ivar's + // comment above. + _nsHasPendingContentSize = false; +} + +- (void)prepareForRecycle { +#ifndef NDEBUG + // M1 review §2/(c) verification: `ctx.emit` from INSIDE the dispose hook + // cannot prove which teardown path ran; confirmed on-sim that Fabric's + // EventEmitter silently no-ops events dispatched at this exact lifecycle + // point even when `_eventEmitter` is a live, non-null pointer (the + // shadow node/surface side has already detached by the time -invalidate/ + // -prepareForRecycle run; upstream RNS never emits from here either, for + // the same reason). This NSLog + `log show`-based assertion in + // scripts/test_react_native_turbomodule_m1.sh is the actual proof. + NSLog(@"NativeScriptComponentView[%@] -prepareForRecycle nsCreated=%d", self.nsComponentName, _nsCreated); +#endif + [self nsDisposeViaInvalidate:NO]; + [super prepareForRecycle]; +} + +- (void)invalidate { +#ifndef NDEBUG + NSLog(@"NativeScriptComponentView[%@] -invalidate nsCreated=%d", self.nsComponentName, _nsCreated); +#endif + [self nsDisposeViaInvalidate:YES]; + [super invalidate]; +} + +#pragma mark - ctx.emit / ctx.setContentSize targets + +- (void)nativeScriptDispatchEventName:(const std::string&)name payload:(folly::dynamic&&)payload { + if (name.empty()) { + return; + } + if (_eventEmitter == nullptr) { + // No emitter yet; buffer instead of dropping (see -nsEnsureCreated's + // comment for why `create` can run before -updateEventEmitter: has + // fired). -updateEventEmitter: flushes this the moment one exists. + _nsPendingEvents.emplace_back(name, std::move(payload)); + return; + } + _eventEmitter->dispatchEvent(name, std::move(payload)); +} + +- (void)nsFlushPendingEvents { + if (_nsPendingEvents.empty() || _eventEmitter == nullptr) { + return; + } + auto pending = std::move(_nsPendingEvents); + _nsPendingEvents.clear(); + for (auto& entry : pending) { + _eventEmitter->dispatchEvent(entry.first, std::move(entry.second)); + } +} + +- (void)nativeScriptSetContentSizeWidth:(double)width + height:(double)height + offsetY:(double)offsetY + authority:(BOOL)authority { + NativeScriptState newState{ + .contentSize = facebook::react::Size{(facebook::react::Float)width, (facebook::react::Float)height}, + .contentOffsetY = (facebook::react::Float)offsetY, + .nativeSizeAuthority = authority == YES, + }; + auto concreteState = + std::static_pointer_cast>(_nsState); + if (concreteState == nullptr) { + // No state yet (e.g. called from `create()`, before -updateState: + // oldState: has ever fired; see the ivar's own comment); buffer + // rather than silently drop; -updateState:oldState: flushes this the + // moment a real state pointer exists. + _nsHasPendingContentSize = true; + _nsPendingContentSize = newState; + return; + } + concreteState->updateState(std::move(newState)); +} + +@end + +void NativeScriptInstallComponentHostFunctions(jsi::Runtime& runtime) { + if (runtime.global().hasProperty(runtime, "__nativeScriptComponentEmit")) { + return; // Already installed on this UI runtime instance. + } + + auto emitFn = jsi::Function::createFromHostFunction( + runtime, jsi::PropNameID::forAscii(runtime, "__nativeScriptComponentEmit"), 3, + [](jsi::Runtime& rt, const jsi::Value&, const jsi::Value* args, size_t count) -> jsi::Value { + if (count < 2 || !args[1].isString()) { + return jsi::Value::undefined(); + } + void* viewPtr = nativescript::NativeScriptUnwrapNativeObject(rt, args[0]); + if (viewPtr == nullptr) { + return jsi::Value::undefined(); + } + NativeScriptComponentView* view = (__bridge NativeScriptComponentView*)viewPtr; + std::string name = args[1].asString(rt).utf8(rt); + folly::dynamic payload = + count > 2 && !args[2].isUndefined() ? jsi::dynamicFromValue(rt, args[2]) : folly::dynamic::object(); + [view nativeScriptDispatchEventName:name payload:std::move(payload)]; + return jsi::Value::undefined(); + }); + runtime.global().setProperty(runtime, "__nativeScriptComponentEmit", std::move(emitFn)); + + auto setContentSizeFn = jsi::Function::createFromHostFunction( + runtime, jsi::PropNameID::forAscii(runtime, "__nativeScriptComponentSetContentSize"), 5, + [](jsi::Runtime& rt, const jsi::Value&, const jsi::Value* args, size_t count) -> jsi::Value { + if (count < 3) { + return jsi::Value::undefined(); + } + void* viewPtr = nativescript::NativeScriptUnwrapNativeObject(rt, args[0]); + if (viewPtr == nullptr) { + return jsi::Value::undefined(); + } + NativeScriptComponentView* view = (__bridge NativeScriptComponentView*)viewPtr; + double width = args[1].isNumber() ? args[1].getNumber() : 0; + double height = args[2].isNumber() ? args[2].getNumber() : 0; + double offsetY = count > 3 && args[3].isNumber() ? args[3].getNumber() : 0; + bool authority = count <= 4 || !args[4].isBool() || args[4].getBool(); + [view nativeScriptSetContentSizeWidth:width height:height offsetY:offsetY authority:authority ? YES : NO]; + return jsi::Value::undefined(); + }); + runtime.global().setProperty(runtime, "__nativeScriptComponentSetContentSize", std::move(setContentSizeFn)); + + auto scheduleFn = jsi::Function::createFromHostFunction( + runtime, jsi::PropNameID::forAscii(runtime, "__nativeScriptComponentScheduleOnMainQueue"), 1, + [](jsi::Runtime& rt, const jsi::Value&, const jsi::Value* args, size_t count) -> jsi::Value { + if (count < 1 || !args[0].isObject() || !args[0].asObject(rt).isFunction(rt)) { + return jsi::Value::undefined(); + } + auto callback = std::make_shared(args[0].asObject(rt).asFunction(rt)); + // M1 review §5/#5: two latent lifetime bugs here; (a) if a + // Worklets reload installs a NEW UI runtime between this call and + // the dispatch_async firing, `callback` (a jsi::Value bound to the + // OLD runtime) must never be `.call()`ed against the new one + // (jsi::Value used with the wrong Runtime is UB); (b) `callback`'s + // shared_ptr must not be destructed (its destructor talks back to + // the Runtime that created it) once that runtime is itself already + // torn down. Fix: generation-tag at schedule time; on mismatch, + // skip the call AND deliberately leak the jsi::Function (see + // NativeScriptLeakScheduledCallback below) instead of letting the + // block's normal teardown destruct it against a dead runtime. + uint64_t scheduledGeneration = nativescript::NativeScriptFabricGatewayGeneration(); + // Genuine deferral to the next main runloop turn; the RNS + // `didMount -> dispatch_async(main)` idiom (RNSScreenStack.mm:1357-1359). + // Deliberately NOT `worklets::scheduleOnUI` (which may run inline + // when already on main; see NativeScriptFabricGateway.h's note on + // why that helper is reserved for the general async-entry path). + dispatch_async(dispatch_get_main_queue(), ^{ + if (nativescript::NativeScriptFabricGatewayGeneration() != scheduledGeneration) { + NativeScriptLeakScheduledCallback(callback); + return; + } + nativescript::NativeScriptFabricGatewayRunSyncOnMain([callback](jsi::Runtime& rt2) -> bool { + callback->call(rt2); + return true; + }); + }); + return jsi::Value::undefined(); + }); + runtime.global().setProperty(runtime, "__nativeScriptComponentScheduleOnMainQueue", std::move(scheduleFn)); +} diff --git a/packages/react-native/ios/Fabric/NativeScriptUIViewComponentView.h b/packages/react-native/ios/Fabric/NativeScriptUIViewComponentView.h deleted file mode 100644 index c6567eb54..000000000 --- a/packages/react-native/ios/Fabric/NativeScriptUIViewComponentView.h +++ /dev/null @@ -1,4 +0,0 @@ -#import - -@interface NativeScriptUIViewComponentView : RCTViewComponentView -@end diff --git a/packages/react-native/ios/Fabric/NativeScriptUIViewComponentView.mm b/packages/react-native/ios/Fabric/NativeScriptUIViewComponentView.mm deleted file mode 100644 index f94f048bb..000000000 --- a/packages/react-native/ios/Fabric/NativeScriptUIViewComponentView.mm +++ /dev/null @@ -1,296 +0,0 @@ -#import "NativeScriptUIViewComponentView.h" - -#import -#import -#import -#import -#import - -#import "NativeScriptUIView.h" - -using namespace facebook::react; - -static BOOL NativeScriptFabricViewIsDescendantOfView(UIView* view, UIView* ancestor) { - UIView* current = view; - while (current != nil) { - if (current == ancestor) { - return YES; - } - current = current.superview; - } - return NO; -} - -static CGRect NativeScriptFabricEffectiveTabBarHitBounds(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 BOOL NativeScriptFabricPointInsideTabBarHitArea(UITabBar* tabBar, UIWindow* window, - CGPoint windowPoint) { - if (tabBar == nil || tabBar.hidden || tabBar.alpha <= 0.01 || - !tabBar.userInteractionEnabled) { - return NO; - } - - CGPoint localPoint = [tabBar convertPoint:windowPoint fromView:window]; - return CGRectContainsPoint(NativeScriptFabricEffectiveTabBarHitBounds(tabBar), localPoint); -} - -static UITabBar* NativeScriptFabricVisibleTabBarAtPoint(UIView* root, UIWindow* window, - CGPoint windowPoint) { - if (root.hidden || root.alpha <= 0.01 || !root.userInteractionEnabled) { - return nil; - } - - if ([root isKindOfClass:UITabBar.class]) { - UITabBar* tabBar = static_cast(root); - if (NativeScriptFabricPointInsideTabBarHitArea(tabBar, window, windowPoint)) { - return static_cast(root); - } - } - - for (UIView* subview in [root.subviews reverseObjectEnumerator]) { - UITabBar* tabBar = NativeScriptFabricVisibleTabBarAtPoint(subview, window, windowPoint); - if (tabBar != nil) { - return tabBar; - } - } - - return nil; -} - -@interface NativeScriptUIViewComponentView () -@end - -@implementation NativeScriptUIViewComponentView { - NativeScriptUIView* _containerView; - NSString* _debugName; -} - -- (instancetype)initWithFrame:(CGRect)frame { - if (self = [super initWithFrame:frame]) { - static const auto defaultProps = std::make_shared(); - _props = defaultProps; - - _containerView = [[NativeScriptUIView alloc] initWithFrame:self.bounds]; - _containerView.hostReadyDelegate = self; - _containerView.autoresizingMask = - UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; - self.contentView = _containerView; - } - - return self; -} - -- (void)dealloc { - _containerView.hostReadyDelegate = nil; - [_debugName release]; - [_containerView release]; - [super dealloc]; -} - -- (void)nativeScriptUIView:(NativeScriptUIView*)view - didHostReady:(NSDictionary*)event { - (void)view; - if (_eventEmitter == nullptr) { - return; - } - - static_cast(*_eventEmitter) - .onHostReady(NativeScriptUIViewEventEmitter::OnHostReady{ - .hostReadyId = RCTStringFromNSString(event[@"hostReadyId"] ?: @""), - .hostId = RCTStringFromNSString(event[@"hostId"] ?: @""), - .nativeViewHandle = RCTStringFromNSString(event[@"nativeViewHandle"] ?: @""), - .childrenViewHandle = RCTStringFromNSString(event[@"childrenViewHandle"] ?: @""), - .controllerHandle = RCTStringFromNSString(event[@"controllerHandle"] ?: @""), - .hasChildren = [event[@"hasChildren"] boolValue], - }); -} - -- (NSString*)description { - if (_debugName.length == 0) { - return [super description]; - } - - NSString* description = [super description]; - if ([description hasSuffix:@">"]) { - return [[description substringToIndex:description.length - 1] - stringByAppendingFormat:@"; debugName = %@>", _debugName]; - } - return [description stringByAppendingFormat:@" debugName = %@", _debugName]; -} - -- (void)mountChildComponentView:(UIView*)childComponentView - index:(NSInteger)index { - [_containerView insertSubview:childComponentView atIndex:index]; - [_containerView refreshDetachedChildrenHost]; -} - -- (void)unmountChildComponentView:(UIView*)childComponentView - index:(NSInteger)index { - [childComponentView removeFromSuperview]; - [_containerView refreshDetachedChildrenHost]; -} - -- (void)didMoveToWindow { - [super didMoveToWindow]; - [_containerView refreshDetachedChildrenHost]; -} - -- (void)layoutSubviews { - [super layoutSubviews]; - [_containerView refreshDetachedChildrenHost]; -} - -- (void)updateLayoutMetrics:(const LayoutMetrics&)layoutMetrics - oldLayoutMetrics:(const LayoutMetrics&)oldLayoutMetrics { - [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics]; - [_containerView refreshDetachedChildrenHost]; -} - -- (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent*)event { - [_containerView refreshDetachedChildrenHost]; - - UIView* hitView = [super hitTest:point withEvent:event]; - if (hitView == nil && _containerView != nil && _containerView.window != nil) { - CGPoint containerPoint = [_containerView convertPoint:point fromView:self]; - hitView = [_containerView hitTest:containerPoint withEvent:event]; - } - - if (hitView == nil || self.window == nil) { - return hitView; - } - - CGPoint windowPoint = [self convertPoint:point toView:self.window]; - UITabBar* tabBar = NativeScriptFabricVisibleTabBarAtPoint(self.window, self.window, windowPoint); - if (tabBar != nil) { - if (NativeScriptFabricViewIsDescendantOfView(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 (!NativeScriptFabricViewIsDescendantOfView(self, tabBar)) { - return nil; - } - } - - return hitView; -} - -- (void)updateProps:(Props::Shared const&)props oldProps:(Props::Shared const&)oldProps { - const auto oldViewProps = std::static_pointer_cast(_props); - const auto newViewProps = std::static_pointer_cast(props); - const std::string oldNativeViewHandle = oldViewProps->nativeViewHandle; - const std::string newNativeViewHandle = newViewProps->nativeViewHandle; - const std::string oldChildrenViewHandle = oldViewProps->childrenViewHandle; - const std::string newChildrenViewHandle = newViewProps->childrenViewHandle; - const std::string oldControllerHandle = oldViewProps->controllerHandle; - const std::string newControllerHandle = newViewProps->controllerHandle; - const auto oldDetachControllerView = oldViewProps->detachControllerView; - const auto newDetachControllerView = newViewProps->detachControllerView; - const std::string oldDebugName = oldViewProps->debugName; - const std::string newDebugName = newViewProps->debugName; - const std::string oldHostId = oldViewProps->hostId; - const std::string newHostId = newViewProps->hostId; - const std::string oldHostReadyId = oldViewProps->hostReadyId; - const std::string newHostReadyId = newViewProps->hostReadyId; - const auto oldUpdateRevision = oldViewProps->updateRevision; - const auto newUpdateRevision = newViewProps->updateRevision; - const auto oldMountedRevision = oldViewProps->mountedRevision; - const auto newMountedRevision = newViewProps->mountedRevision; - - [super updateProps:props oldProps:oldProps]; - - if (oldDebugName != newDebugName) { - NSString* debugName = - newDebugName.empty() ? nil : [NSString stringWithUTF8String:newDebugName.c_str()]; - [_debugName release]; - _debugName = [debugName copy]; - _containerView.debugName = debugName; - } - - if (oldDetachControllerView != newDetachControllerView) { - _containerView.detachControllerView = newDetachControllerView; - } - - if (oldNativeViewHandle != newNativeViewHandle) { - NSString* nativeViewHandle = newNativeViewHandle.empty() - ? nil - : [NSString stringWithUTF8String:newNativeViewHandle.c_str()]; - _containerView.nativeViewHandle = nativeViewHandle; - } - - if (oldChildrenViewHandle != newChildrenViewHandle) { - NSString* childrenViewHandle = - newChildrenViewHandle.empty() - ? nil - : [NSString stringWithUTF8String:newChildrenViewHandle.c_str()]; - _containerView.childrenViewHandle = childrenViewHandle; - } - - if (oldControllerHandle != newControllerHandle) { - NSString* controllerHandle = newControllerHandle.empty() - ? nil - : [NSString stringWithUTF8String:newControllerHandle.c_str()]; - _containerView.controllerHandle = controllerHandle; - } - - if (oldHostId != newHostId) { - NSString* hostId = newHostId.empty() ? nil : [NSString stringWithUTF8String:newHostId.c_str()]; - _containerView.hostId = hostId; - } - - if (oldHostReadyId != newHostReadyId) { - NSString* hostReadyId = newHostReadyId.empty() - ? nil - : [NSString stringWithUTF8String:newHostReadyId.c_str()]; - _containerView.hostReadyId = hostReadyId; - } - - if (oldUpdateRevision != newUpdateRevision) { - _containerView.updateRevision = newUpdateRevision; - } - - if (oldMountedRevision != newMountedRevision) { - _containerView.mountedRevision = newMountedRevision; - } - - [_containerView refreshDetachedChildrenHost]; -} - -- (void)prepareForRecycle { - [super prepareForRecycle]; - [_debugName release]; - _debugName = nil; - _containerView.hostId = nil; - _containerView.hostReadyId = nil; - _containerView.debugName = nil; - _containerView.nativeViewHandle = nil; - _containerView.childrenViewHandle = nil; - _containerView.controllerHandle = nil; - _containerView.detachControllerView = NO; - _containerView.updateRevision = 0; - _containerView.mountedRevision = 0; -} - -+ (ComponentDescriptorProvider)componentDescriptorProvider { - return concreteComponentDescriptorProvider(); -} - -@end - -Class NativeScriptUIViewCls(void) { - return NativeScriptUIViewComponentView.class; -} diff --git a/packages/react-native/ios/NativeScriptFabricGateway.h b/packages/react-native/ios/NativeScriptFabricGateway.h new file mode 100644 index 000000000..850e3ba31 --- /dev/null +++ b/packages/react-native/ios/NativeScriptFabricGateway.h @@ -0,0 +1,111 @@ +#pragma once + +// Connects native Fabric callbacks to the Worklets UI runtime. TypeScript owns +// component instances and hook dispatch. This gateway stores the runtime, +// scheduler, generation number, and serialized component definitions. + +#include +#include +#include + +#import + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace nativescript { + +// Stores a weak runtime reference and increments the generation when Worklets +// installs a new UI runtime. Generation checks invalidate materialized specs +// after a reload. +void NativeScriptFabricGatewaySetUIRuntime(std::shared_ptr runtime); +std::shared_ptr NativeScriptFabricGatewayGetUIRuntime(); +uint64_t NativeScriptFabricGatewayGeneration(); + +// Stores the Worklets scheduler used for asynchronous UI-runtime entry. +void NativeScriptFabricGatewaySetUIScheduler(std::shared_ptr scheduler); + +// Schedules `job` through Worklets. The scheduler may run it inline on the +// main thread. `ctx.scheduleOnMainQueue` uses dispatch_async separately when +// it must wait for the next run-loop turn. +void NativeScriptFabricGatewayScheduleOnUI(std::function job); + +// Returns true on the thread allowed to enter the UI runtime synchronously. +bool NativeScriptFabricGatewayIsOnEntryThread(); + +/* + * Enters the UI runtime synchronously and returns `job(rt)`. Call this only + * from the main thread. Debug builds assert when that contract is broken. + * + * Returns a default-constructed Result (via the bool out-param) if no UI + * runtime is currently installed (e.g. called before bootstrap, or after the + * UI VM was torn down by a Worklets reload and not yet reinstalled). + */ +template +auto NativeScriptFabricGatewayRunSyncOnMain(Callable&& job, bool* ranOut = nullptr) + -> decltype(job(std::declval())) { + using Result = decltype(job(std::declval())); + +#ifndef NDEBUG + if (!NativeScriptFabricGatewayIsOnEntryThread()) { + // Synchronous entry from another thread can deadlock with the main queue. + NSLog(@"NativeScriptFabricGateway: runSyncOnMain called off the main " + @"thread. Enter the UI runtime from the main thread."); + assert(false && "NativeScriptFabricGatewayRunSyncOnMain called off-main"); + } +#endif + + auto runtime = NativeScriptFabricGatewayGetUIRuntime(); + if (runtime == nullptr) { + if (ranOut != nullptr) { + *ranOut = false; + } + return Result{}; + } + + if (ranOut != nullptr) { + *ranOut = true; + } + return runtime->runSync(std::forward(job)); +} + +// Stores the serialized definition and hook mask for each component name. +struct NativeScriptComponentSpecEntry { + std::shared_ptr serializable; + uint32_t hookMask = 0; +}; + +void NativeScriptFabricGatewayRegisterComponentSpec(const std::string& name, + std::shared_ptr serializable, + uint32_t hookMask); +uint32_t NativeScriptFabricGatewayHookMaskForComponent(const std::string& name); + +// Hook bits let native code skip dispatch for hooks a component did not define. +// Creation is unconditional and therefore needs no bit. +enum NativeScriptComponentHook : uint32_t { + NativeScriptComponentHookUpdateProps = 1 << 0, + NativeScriptComponentHookMountChild = 1 << 1, + NativeScriptComponentHookUnmountChild = 1 << 2, + NativeScriptComponentHookWillMount = 1 << 3, + NativeScriptComponentHookDidMount = 1 << 4, + NativeScriptComponentHookUpdateLayoutMetrics = 1 << 5, + NativeScriptComponentHookFinalizeUpdates = 1 << 6, + NativeScriptComponentHookPrepareForRecycle = 1 << 7, + NativeScriptComponentHookCommands = 1 << 8, +}; + +// Materializes a component definition once per runtime generation, then calls +// the TypeScript hook dispatcher. The caller must already hold the UI runtime. +// Returns undefined until the component and dispatcher are registered. +facebook::jsi::Value NativeScriptFabricGatewayDispatchComponentHook( + facebook::jsi::Runtime& rt, const std::string& name, double tag, const std::string& hookName, + const facebook::jsi::Value& view, const facebook::jsi::Value& a, const facebook::jsi::Value& b, + const facebook::jsi::Value& c); + +} // namespace nativescript diff --git a/packages/react-native/ios/NativeScriptFabricGateway.mm b/packages/react-native/ios/NativeScriptFabricGateway.mm new file mode 100644 index 000000000..d80b456d9 --- /dev/null +++ b/packages/react-native/ios/NativeScriptFabricGateway.mm @@ -0,0 +1,189 @@ +#include "NativeScriptFabricGateway.h" + +#import +#import + +#include +#include + +using facebook::jsi::Function; +using facebook::jsi::Object; +using facebook::jsi::Runtime; +using facebook::jsi::Value; + +namespace nativescript { + +namespace { + +std::mutex& UIRuntimeMutex() { + static std::mutex mutex; + return mutex; +} + +std::weak_ptr& UIRuntimeWeak() { + static std::weak_ptr runtime; + return runtime; +} + +std::atomic& UIRuntimeGenerationCounter() { + static std::atomic generation{0}; + return generation; +} + +std::shared_ptr& UISchedulerStorage() { + static std::shared_ptr scheduler; + return scheduler; +} + +std::mutex& ComponentSpecMutex() { + static std::mutex mutex; + return mutex; +} + +std::unordered_map& ComponentSpecs() { + static std::unordered_map specs; + return specs; +} + +// name -> the UI-runtime generation TS last confirmed it has a materialized +// copy of that name's spec for. Reset implicitly by generation mismatch +// (never explicitly cleared; stale entries for old generations are simply +// never matched again). +std::unordered_map& MaterializedGenerationByName() { + static std::unordered_map materialized; + return materialized; +} + +} // namespace + +void NativeScriptFabricGatewaySetUIRuntime(std::shared_ptr runtime) { + std::lock_guard lock(UIRuntimeMutex()); + UIRuntimeWeak() = runtime; + if (runtime != nullptr) { + UIRuntimeGenerationCounter().fetch_add(1, std::memory_order_relaxed); + } +} + +std::shared_ptr NativeScriptFabricGatewayGetUIRuntime() { + std::lock_guard lock(UIRuntimeMutex()); + return UIRuntimeWeak().lock(); +} + +uint64_t NativeScriptFabricGatewayGeneration() { + return UIRuntimeGenerationCounter().load(std::memory_order_relaxed); +} + +void NativeScriptFabricGatewaySetUIScheduler(std::shared_ptr scheduler) { + std::lock_guard lock(UIRuntimeMutex()); + UISchedulerStorage() = std::move(scheduler); +} + +void NativeScriptFabricGatewayScheduleOnUI(std::function job) { + std::shared_ptr scheduler; + { + std::lock_guard lock(UIRuntimeMutex()); + scheduler = UISchedulerStorage(); + } + if (scheduler != nullptr) { + worklets::scheduleOnUI(scheduler, job); + return; + } + // No UIScheduler installed yet (e.g. called before bootstrap); fall back + // to a plain main-queue hop rather than dropping the job. Still "no + // blocking cross-thread waits" (§3.4): dispatch_async, never _sync. + auto jobBox = std::make_shared>(std::move(job)); + dispatch_async(dispatch_get_main_queue(), ^{ + (*jobBox)(); + }); +} + +bool NativeScriptFabricGatewayIsOnEntryThread() { + return pthread_main_np() != 0; +} + +void NativeScriptFabricGatewayRegisterComponentSpec(const std::string& name, + std::shared_ptr serializable, + uint32_t hookMask) { + std::lock_guard lock(ComponentSpecMutex()); + ComponentSpecs()[name] = NativeScriptComponentSpecEntry{std::move(serializable), hookMask}; + // M1 review §3/#4 + §5/#4: a fast-refresh re-invocation of + // `defineNativeComponent("name", ...)` lands a NEW serializable here + // WITHOUT the UI runtime's generation having changed; if + // `MaterializedGenerationByName()[name]` still says "already materialized + // for the current generation", DispatchComponentHook's own generation + // check (below) would never re-materialize, and the UI runtime keeps + // executing the STALE hooks from the previous edit until a full reload. + // Erasing here forces the next dispatch to re-materialize unconditionally, + // regardless of whether the generation itself moved. + MaterializedGenerationByName().erase(name); +} + +uint32_t NativeScriptFabricGatewayHookMaskForComponent(const std::string& name) { + std::lock_guard lock(ComponentSpecMutex()); + auto it = ComponentSpecs().find(name); + return it != ComponentSpecs().end() ? it->second.hookMask : 0; +} + +Value NativeScriptFabricGatewayDispatchComponentHook(Runtime& rt, const std::string& name, double tag, + const std::string& hookName, const Value& view, + const Value& a, const Value& b, const Value& c) { + uint64_t currentGeneration = NativeScriptFabricGatewayGeneration(); + + // 1. Ensure TS has this name's materialized spec for the current + // generation (at most once per name per generation). + bool needsMaterialize = false; + { + std::lock_guard lock(ComponentSpecMutex()); + auto materializedIt = MaterializedGenerationByName().find(name); + needsMaterialize = + materializedIt == MaterializedGenerationByName().end() || materializedIt->second != currentGeneration; + } + if (needsMaterialize) { + std::shared_ptr serializable; + { + std::lock_guard lock(ComponentSpecMutex()); + auto specIt = ComponentSpecs().find(name); + if (specIt != ComponentSpecs().end()) { + serializable = specIt->second.serializable; + } + } + if (serializable == nullptr) { + return Value::undefined(); + } + Value registerFnValue = rt.global().getProperty(rt, "__nativeScriptRegisterMaterializedSpec"); + if (!registerFnValue.isObject() || !registerFnValue.asObject(rt).isFunction(rt)) { + return Value::undefined(); + } + Value specValue = serializable->toJSValue(rt); + registerFnValue.asObject(rt).asFunction(rt).call( + rt, Value(rt, facebook::jsi::String::createFromUtf8(rt, name)), std::move(specValue)); + std::lock_guard lock(ComponentSpecMutex()); + MaterializedGenerationByName()[name] = currentGeneration; + } + + // 2. Fetch the one TS dispatcher function; a fresh global-object property + // lookup every call (same pattern as step 1's + // __nativeScriptRegisterMaterializedSpec lookup above), NOT cached across + // calls. A previous version of this function cached the resolved + // `jsi::Function` in a process-lifetime `static std::shared_ptr` + // keyed by generation; that crashed (SIGSEGV in jsi::Function::~Function, + // observed via a real on-sim debug-configuration run) even on a FIRST-EVER + // dispatch, before any reload/generation change could be involved -- + // holding a jsi::Function handle in a static that outlives the call stack + // it was resolved in is exactly the kind of "per-crossing memo machinery" + // DECISIONS.md's "Never reintroduce" list warns about, and a HashMap + // lookup on the global object per hook dispatch is not worth reintroducing + // that risk for. See the M1 verification report for the crash detail. + Value dispatchFnValue = rt.global().getProperty(rt, "__nativeScriptDispatchComponentHook"); + if (!dispatchFnValue.isObject() || !dispatchFnValue.asObject(rt).isFunction(rt)) { + return Value::undefined(); + } + Function dispatchFn = dispatchFnValue.asObject(rt).asFunction(rt); + + return dispatchFn.call( + rt, Value(rt, facebook::jsi::String::createFromUtf8(rt, name)), tag, + Value(rt, facebook::jsi::String::createFromUtf8(rt, hookName)), Value(rt, view), Value(rt, a), + Value(rt, b), Value(rt, c)); +} + +} // namespace nativescript diff --git a/packages/react-native/ios/NativeScriptNativeApiModule.h b/packages/react-native/ios/NativeScriptNativeApiModule.h index ebe82a47f..cf3d3b556 100644 --- a/packages/react-native/ios/NativeScriptNativeApiModule.h +++ b/packages/react-native/ios/NativeScriptNativeApiModule.h @@ -15,12 +15,46 @@ class NativeScriptNativeApiModule explicit NativeScriptNativeApiModule(std::shared_ptr jsInvoker); bool install(jsi::Runtime& runtime, std::string metadataPath); - bool installWorkletRuntime(jsi::Runtime& runtime, jsi::Object runtimeHolder, - std::string metadataPath); + // `schedulerHolder` is the UIScheduler holder handshake (ARCHITECTURE.md + // §3.3/§7.1) alongside M0's WorkletRuntime holder; lets the gateway + // route off-main async entries through the sanctioned + // `worklets::scheduleOnUI` instead of a raw `dispatch_async(main)`. + bool installUIRuntime(jsi::Runtime& runtime, jsi::Object runtimeHolder, + jsi::Object schedulerHolder, std::string metadataPath); bool isInstalled(jsi::Runtime& runtime); std::string defaultMetadataPath(jsi::Runtime& runtime); std::string getRuntimeBackend(jsi::Runtime& runtime); bool __writeTestMarker(jsi::Runtime& runtime, std::string content); + // Test-only companion to __writeTestMarker, symmetric read-back of the + // SAME smoke-marker file (used by callers that just want to see the + // latest progress/result marker; NOT used for JOB2 phase-tracking -- + // see __writeReloadPhaseMarker below for why that needs its own file). + std::string __readTestMarker(jsi::Runtime& runtime); + // JOB2 dev-reload test: a SEPARATE marker file from the smoke marker + // above. A DevSettings.reload() cycle re-runs the native install + // sequence, which writes its own "stage=..." progress markers to the + // smoke-marker file via writeSmokeMarkerIfRequested; reusing that same + // file for "did my previous JS-side phase already run" round-tripping + // caused an infinite reload loop (native's own install-stage write + // clobbered the phase marker before the reloaded JS ever read it back; + // confirmed on-sim). This pair is immune to that because nothing else + // writes to NativeScriptM1ReloadPhase.marker. + bool __writeReloadPhaseMarker(jsi::Runtime& runtime, std::string content); + std::string __readReloadPhaseMarker(jsi::Runtime& runtime); + + // `defineNativeComponent`'s native registration step (ARCHITECTURE.md + // §5.2 step 1-2): extracts a worklets Serializable from `spec` -- + // synchronously, on the JS thread, no UI-runtime entry needed; and + // stores it in the gateway's spec store keyed by `name`, alongside + // `hookMask` (bitwise-OR of NativeScriptComponentHook). Also performs the + // Fabric flavored-class registration (NativeScriptRegisterFlavoredComponent). + // Synchronous/blocking by design: by the time this call returns, the + // component is fully registered, so there is no ordering race between + // "definition shipped" and Fabric's first mount of it (§5.2). + // `shouldBeRecycled`: tri-state number (-1 unspecified, 0/1 false/true -- + // see NativeScriptNativeApi.ts's Spec comment). + bool registerComponent(jsi::Runtime& runtime, std::string name, jsi::Object spec, double hookMask, + double shouldBeRecycled); private: std::shared_ptr jsInvoker_; diff --git a/packages/react-native/ios/NativeScriptNativeApiModule.mm b/packages/react-native/ios/NativeScriptNativeApiModule.mm index 99722918c..206efbc2d 100644 --- a/packages/react-native/ios/NativeScriptNativeApiModule.mm +++ b/packages/react-native/ios/NativeScriptNativeApiModule.mm @@ -9,7 +9,9 @@ #include #include "NativeApiJsiReactNative.h" -#include "NativeScriptUIKitHost.h" +#include "NativeScriptFabricGateway.h" +#include "Fabric/NativeScriptComponentRegistration.h" +#include "Fabric/NativeScriptComponentView.h" #import #import @@ -20,6 +22,10 @@ #include #include +#import + +#include + namespace { std::string pathForResource(NSBundle* bundle, NSString* name, NSString* type) { @@ -95,6 +101,72 @@ bool writeSmokeMarkerContentIfRequested(const std::string& content) { return ok == YES; } +// Dev-reload (JOB2) phase tracking; deliberately a SEPARATE file from the +// smoke marker above. First cut of this reused the smoke-marker file/path +// for both; that broke (infinite reload loop, observed on-sim) because +// writeSmokeMarkerIfRequested's OWN install-milestone writes +// ("stage=engine:installed" etc, fired by the native re-install sequence a +// DevSettings.reload() triggers) clobber it before the reloaded JS ever +// gets to read back what it wrote as "phase 1 done". A dedicated file next +// to it is immune to that. +NSString* reloadPhaseMarkerPath() { + return [NSTemporaryDirectory() stringByAppendingPathComponent:@"NativeScriptM1ReloadPhase.marker"]; +} + +bool writeReloadPhaseMarkerIfRequested(const std::string& content) { + const char* enabled = getenv("NATIVESCRIPT_RN_TURBO_SMOKE_MARKER"); + if (enabled == nullptr || enabled[0] == '\0') { + return false; + } + NSString* nativeContent = [[NSString alloc] initWithBytes:content.data() + length:content.size() + encoding:NSUTF8StringEncoding]; + if (nativeContent == nil) { + nativeContent = @""; + } + BOOL ok = [nativeContent writeToFile:reloadPhaseMarkerPath() atomically:YES encoding:NSUTF8StringEncoding error:nil]; +#if !__has_feature(objc_arc) + [nativeContent release]; +#endif + return ok == YES; +} + +std::string readReloadPhaseMarkerIfRequested() { + const char* enabled = getenv("NATIVESCRIPT_RN_TURBO_SMOKE_MARKER"); + if (enabled == nullptr || enabled[0] == '\0') { + return ""; + } + NSString* content = [NSString stringWithContentsOfFile:reloadPhaseMarkerPath() + encoding:NSUTF8StringEncoding + error:nil]; + if (content == nil) { + return ""; + } + return std::string(content.UTF8String != nullptr ? content.UTF8String : ""); +} + +// Symmetric to writeSmokeMarkerContentIfRequested above; test-only (same +// NATIVESCRIPT_RN_TURBO_SMOKE_MARKER gate), used by the M1 dev-reload test +// (JOB2) so JS can detect "did a previous phase already run" by reading +// back its own marker file across a DevSettings.reload() cycle, which tears +// down the JS VM (and any JS-side globals) but not the on-disk file or this +// TurboModule's process. Returns "" if disabled, unreadable, or absent -- +// never throws, so a pre-first-write read is a normal, expected case. +std::string readSmokeMarkerContentIfRequested() { + const char* enabled = getenv("NATIVESCRIPT_RN_TURBO_SMOKE_MARKER"); + if (enabled == nullptr || enabled[0] == '\0') { + return ""; + } + + NSString* path = + [NSTemporaryDirectory() stringByAppendingPathComponent:@"NativeScriptNativeApiSmoke.marker"]; + NSString* content = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil]; + if (content == nil) { + return ""; + } + return std::string(content.UTF8String != nullptr ? content.UTF8String : ""); +} + bool nativeApiInstalled(facebook::jsi::Runtime& runtime) { return runtime.global().hasProperty(runtime, "__nativeScriptNativeApi"); } @@ -136,36 +208,6 @@ bool nativeApiInstalled(facebook::jsi::Runtime& runtime) { : UIImageRenderingModeAlwaysOriginal]; } -std::mutex& nativeScriptWorkletRuntimeMutex() { - static std::mutex mutex; - return mutex; -} - -std::weak_ptr& nativeScriptWorkletRuntime() { - static std::weak_ptr runtime; - return runtime; -} - -void setNativeScriptWorkletRuntime(std::shared_ptr runtime) { - std::lock_guard lock(nativeScriptWorkletRuntimeMutex()); - nativeScriptWorkletRuntime() = std::move(runtime); -} - -std::shared_ptr getNativeScriptWorkletRuntime() { - std::lock_guard lock(nativeScriptWorkletRuntimeMutex()); - return nativeScriptWorkletRuntime().lock(); -} - -NSString* stringProperty(facebook::jsi::Runtime& runtime, facebook::jsi::Object& object, - const char* name) { - auto value = object.getProperty(runtime, name); - if (!value.isString()) { - return nil; - } - std::string text = value.getString(runtime).utf8(runtime); - return [NSString stringWithUTF8String:text.c_str()]; -} - id imageSourceFromJSIValue(facebook::jsi::Runtime& runtime, const facebook::jsi::Value& value, const std::shared_ptr& jsInvoker) { @@ -218,95 +260,8 @@ void callImageLoadCallback( }); } -NSDictionary* handlesFromJSIValue(facebook::jsi::Runtime& runtime, - facebook::jsi::Value&& result) { - if (!result.isObject()) { - return nil; - } - - auto resultObject = result.asObject(runtime); - NSMutableDictionary* handles = - [NSMutableDictionary dictionaryWithCapacity:3]; - NSString* nativeViewHandle = stringProperty(runtime, resultObject, "nativeViewHandle"); - NSString* childrenViewHandle = stringProperty(runtime, resultObject, "childrenViewHandle"); - NSString* controllerHandle = stringProperty(runtime, resultObject, "controllerHandle"); - - if (nativeViewHandle.length > 0) { - handles[@"nativeViewHandle"] = nativeViewHandle; - } - if (childrenViewHandle.length > 0) { - handles[@"childrenViewHandle"] = childrenViewHandle; - } - if (controllerHandle.length > 0) { - handles[@"controllerHandle"] = controllerHandle; - } - return handles; -} - -NSDictionary* runUIKitHostFunction(NSString* hostId, NSString* phase, - const char* globalName, - const char* logAction) { - if (hostId.length == 0 || ![NSThread isMainThread]) { - return nil; - } - - auto workletRuntime = getNativeScriptWorkletRuntime(); - if (workletRuntime == nullptr) { - return nil; - } - - std::string hostIdString = hostId.UTF8String != nullptr ? hostId.UTF8String : ""; - if (hostIdString.empty()) { - return nil; - } - - std::string phaseString = phase.UTF8String != nullptr ? phase.UTF8String : ""; - - try { - return workletRuntime->runSync( - [hostIdString = std::move(hostIdString), phaseString = std::move(phaseString), - globalName](facebook::jsi::Runtime& runtime) -> NSDictionary* { - auto global = runtime.global(); - auto functionValue = global.getProperty(runtime, globalName); - if (!functionValue.isObject()) { - return nil; - } - - auto functionObject = functionValue.asObject(runtime); - if (!functionObject.isFunction(runtime)) { - return nil; - } - - auto function = functionObject.asFunction(runtime); - auto hostIdValue = facebook::jsi::String::createFromUtf8(runtime, hostIdString); - if (phaseString.empty()) { - return handlesFromJSIValue(runtime, function.call(runtime, hostIdValue)); - } - - return handlesFromJSIValue( - runtime, function.call(runtime, hostIdValue, - facebook::jsi::String::createFromUtf8(runtime, phaseString))); - }); - } catch (const std::exception& error) { - NSLog(@"NativeScript failed to %s UIKit host %@: %s", logAction, hostId, error.what()); - } catch (...) { - NSLog(@"NativeScript failed to %s UIKit host %@", logAction, hostId); - } - return nil; -} - } // namespace -NSDictionary* NativeScriptCreateUIKitHost(NSString* hostId) { - return runUIKitHostFunction(hostId, nil, "__nativeScriptCreateUIKitHostFromNative", "create"); -} - -NSDictionary* NativeScriptRunUIKitHostLifecycle(NSString* hostId, - NSString* phase) { - return runUIKitHostFunction(hostId, phase, "__nativeScriptRunUIKitHostLifecycleFromNative", - "run"); -} - namespace facebook::react { NativeScriptNativeApiModule::NativeScriptNativeApiModule(std::shared_ptr jsInvoker) @@ -329,32 +284,81 @@ void callImageLoadCallback( return isInstalled(runtime); } -bool NativeScriptNativeApiModule::installWorkletRuntime(jsi::Runtime& runtime, - jsi::Object runtimeHolder, - std::string metadataPath) { - writeSmokeMarkerIfRequested("installWorkletRuntime:headers"); +bool NativeScriptNativeApiModule::installUIRuntime(jsi::Runtime& runtime, + jsi::Object runtimeHolder, + jsi::Object schedulerHolder, + std::string metadataPath) { + writeSmokeMarkerIfRequested("installUIRuntime:headers"); if (!runtimeHolder.hasNativeState(runtime)) { - writeSmokeMarkerIfRequested("installWorkletRuntime:no-holder"); + writeSmokeMarkerIfRequested("installUIRuntime:no-holder"); return false; } auto holder = runtimeHolder.getNativeState(runtime); if (holder == nullptr || holder->runtime_ == nullptr) { - writeSmokeMarkerIfRequested("installWorkletRuntime:null-runtime"); + writeSmokeMarkerIfRequested("installUIRuntime:null-runtime"); return false; } - setNativeScriptWorkletRuntime(holder->runtime_); + // The gateway is the single source of truth for the installed UI runtime + // (M1: the old dual-write; a second, separately-maintained weak_ptr here + //; is gone). + nativescript::NativeScriptFabricGatewaySetUIRuntime(holder->runtime_); + + // UIScheduler holder handshake (ARCHITECTURE.md §3.3/§7.1), same unwrap + // pattern as the WorkletRuntime holder just above (StableApi.h's + // getUISchedulerFromHolder, which; unlike the WorkletRuntimeHolder path + // above; throws rather than returning null if the object carries no + // native state, so the hasNativeState check here is load-bearing, not + // defensive noise). Missing/invalid is non-fatal: the gateway's + // ScheduleOnUI falls back to a plain dispatch_async(main) when no + // scheduler is installed, so this never blocks bootstrap. + auto uiScheduler = schedulerHolder.hasNativeState(runtime) + ? worklets::getUISchedulerFromHolder(runtime, schedulerHolder) + : nullptr; + if (uiScheduler != nullptr) { + nativescript::NativeScriptFabricGatewaySetUIScheduler(std::move(uiScheduler)); + } std::string resolvedMetadataPath = metadataPath.empty() ? bundledMetadataPath() : metadataPath; auto jsInvoker = jsInvoker_; auto workletRuntimeRef = holder->runtime_; - return holder->runtime_->runSync( + + // This call itself is the ONE sanctioned exception to "only enter the UI + // runtime from main" (ARCHITECTURE.md §3.3/§9.2): it runs once, at + // bootstrap, before any TS hook exists to race with. But everything it + // installs (host functions, the ObjC bridge's own notion of its "home" + // thread) must behave as if it always runs on main from here on; so we + // hop to main, rather than calling runSync directly from the RN JS thread + // as the refactor baseline did. Otherwise NativeApiBridge captures the JS + // thread as its "home" thread and later, genuinely-main-thread nested + // re-entry (spike 1) takes the wrong (off-home-thread) callback-dispatch + // path. + // + // M1 review §3/#3 (a real contract breach): this used to be + // `dispatch_sync(main)`, called FROM the JS thread; exactly the + // blocking cross-thread wait §3.4 says must never exist, and a live + // AB-BA edge if main is ever itself blocked waiting on the JS thread + // during some other RN synchronous-surface startup path. Fixed per the + // review's own suggested option: `dispatch_async` instead, with the + // gateway's existing "not yet installed" graceful no-op (every Fabric + // hook dispatch already tolerates a runtime with no dispatcher installed + // yet; NativeScriptFabricGatewayDispatchComponentHook returns + // Value::undefined() rather than crashing) covering the now-nonzero + // window between this call returning and the async block actually + // running. That window cannot be observed by a REAL Fabric hook in + // practice: Fabric cannot call anything before React's first commit, + // which cannot happen before this synchronous JS-thread call already + // returned. `installed` can therefore no longer report the async work's + // actual outcome; it now means "accepted for install", matching how + // `runOnUIAsync`-style bootstrap calls already work elsewhere in this file. + bool installed = true; + dispatch_async(dispatch_get_main_queue(), ^{ + workletRuntimeRef->runSync( [jsInvoker = std::move(jsInvoker), resolvedMetadataPath = std::move(resolvedMetadataPath), - workletRuntimeRef = std::move(workletRuntimeRef)]( + workletRuntimeRef]( jsi::Runtime& workletRuntime) -> bool { if (!nativeApiInstalled(workletRuntime)) { - std::weak_ptr workletRuntimeWeak(workletRuntimeRef); const char* metadataPathArg = resolvedMetadataPath.empty() ? nullptr : resolvedMetadataPath.c_str(); auto config = @@ -362,42 +366,24 @@ void callImageLoadCallback( jsInvoker, nullptr, metadataPathArg, nullptr, "__nativeScriptNativeApi"); config.installGlobalSymbols = true; config.invokeCallbacksOnNativeCallerThread = true; - config.runtimeCallbackInvoker = - [workletRuntimeWeak](std::function task) mutable { - 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); - }; + // ARCHITECTURE.md §3.3/§3.4: no blocking cross-thread waits. A + // callback arriving off the UI runtime's home thread is routed + // through the gateway's ScheduleOnUI; the sanctioned + // `worklets::scheduleOnUI` (M1; M0 used a raw dispatch_async(main) + // here and flagged it as the one deviation from the design's + // letter). The DISPATCH_TIME_FOREVER semaphore the refactor + // baseline used here remains deleted, not just widened; both + // paths are fire-and-forget async, never a blocking wait. + config.runtimeCallbackInvoker = [](std::function task) { + nativescript::NativeScriptFabricGatewayScheduleOnUI(std::move(task)); + }; nativescript::InstallNativeApiJSI(workletRuntime, config); } - 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 { - 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 NativeScriptRefreshUIKitHostView(nativeHandle) == YES; - }); - workletRuntime.global().setProperty( - workletRuntime, "__nativeScriptRefreshUIKitHostView", std::move(refreshUIKitHostView)); + // ctx.emit / ctx.setContentSize / ctx.scheduleOnMainQueue targets + // (src/ui/dispatcher.ts); idempotent, safe to call on every + // install (including reload re-installs onto a fresh UI VM). + NativeScriptInstallComponentHostFunctions(workletRuntime); std::weak_ptr imageWorkletRuntimeWeak(workletRuntimeRef); auto loadImage = jsi::Function::createFromHostFunction( @@ -451,7 +437,9 @@ void callImageLoadCallback( workletRuntime, "__nativeScriptLoadReactImage", std::move(loadImage)); return nativeApiInstalled(workletRuntime); }); - } + }); + return installed; +} bool NativeScriptNativeApiModule::isInstalled(jsi::Runtime& runtime) { return nativeApiInstalled(runtime); @@ -470,4 +458,63 @@ void callImageLoadCallback( return writeSmokeMarkerContentIfRequested(content); } +std::string NativeScriptNativeApiModule::__readTestMarker(jsi::Runtime&) { + return readSmokeMarkerContentIfRequested(); +} + +bool NativeScriptNativeApiModule::__writeReloadPhaseMarker(jsi::Runtime&, std::string content) { + return writeReloadPhaseMarkerIfRequested(content); +} + +std::string NativeScriptNativeApiModule::__readReloadPhaseMarker(jsi::Runtime&) { + return readReloadPhaseMarkerIfRequested(); +} + +// --------------------------------------------------------------------------- +// registerComponent (ARCHITECTURE.md §5.2 steps 1-2). M0's three spike* +// entry points (registerFlavoredComponent/spikeRunSyncFromMain/ +// spikeFlavorMountSnapshot) are gone: real Fabric hooks now exercise the +// same gateway path they existed only to prove in isolation. +// --------------------------------------------------------------------------- + +bool NativeScriptNativeApiModule::registerComponent(jsi::Runtime& runtime, std::string name, jsi::Object spec, + double hookMask, double shouldBeRecycled) { + if (name.empty()) { + return false; + } + + // Extraction happens HERE, on the JS thread, synchronously; it never + // enters the UI runtime (extractSerializable just walks the JS value + // graph). By the time this call returns, `name`'s spec is fully stored; + // Fabric's first mount of a component with this name can only happen + // after React renders it, which can only happen after this call already + // returned; so there is no ordering race between "definition shipped" + // and "first mount" (§5.2 step 1). + std::shared_ptr serializable; + try { + serializable = worklets::extractSerializable( + runtime, jsi::Value(runtime, spec), + "[NativeScript] defineNativeComponent's spec must be serializable by react-native-worklets " + "(plain data plus 'worklet' functions)."); + } catch (const std::exception& error) { + NSLog(@"NativeScript: failed to register component \"%s\": %s", name.c_str(), error.what()); + return false; + } + if (serializable == nullptr) { + return false; + } + + uint32_t hookMaskValue = hookMask > 0 ? static_cast(hookMask) : 0; + nativescript::NativeScriptFabricGatewayRegisterComponentSpec(name, std::move(serializable), hookMaskValue); + + NSString* nsName = [NSString stringWithUTF8String:name.c_str()]; + if (nsName.length == 0) { + return false; + } + BOOL hasShouldBeRecycled = shouldBeRecycled >= 0; + BOOL shouldBeRecycledValue = shouldBeRecycled > 0; + NativeScriptRegisterFlavoredComponent(nsName, hookMaskValue, hasShouldBeRecycled, shouldBeRecycledValue); + return true; +} + } // namespace facebook::react diff --git a/packages/react-native/ios/NativeScriptUIKitHost.h b/packages/react-native/ios/NativeScriptUIKitHost.h deleted file mode 100644 index bec2d9dff..000000000 --- a/packages/react-native/ios/NativeScriptUIKitHost.h +++ /dev/null @@ -1,8 +0,0 @@ -#import - -FOUNDATION_EXPORT NSDictionary* NativeScriptCreateUIKitHost(NSString* hostId); - -FOUNDATION_EXPORT NSDictionary* NativeScriptRunUIKitHostLifecycle( - NSString* hostId, NSString* phase); - -FOUNDATION_EXPORT BOOL NativeScriptRefreshUIKitHostView(NSString* viewHandle); diff --git a/packages/react-native/ios/NativeScriptUIView.h b/packages/react-native/ios/NativeScriptUIView.h deleted file mode 100644 index 8293a0b60..000000000 --- a/packages/react-native/ios/NativeScriptUIView.h +++ /dev/null @@ -1,28 +0,0 @@ -#import -#import - -@class NativeScriptUIView; - -@protocol NativeScriptUIViewHostReadyDelegate -- (void)nativeScriptUIView:(NativeScriptUIView*)view - didHostReady:(NSDictionary*)event; -@end - -@interface NativeScriptUIView : UIView - -@property(nonatomic, copy) NSString* hostId; -@property(nonatomic, copy) NSString* hostReadyId; -@property(nonatomic, copy) NSString* nativeViewHandle; -@property(nonatomic, copy) NSString* childrenViewHandle; -@property(nonatomic, copy) NSString* controllerHandle; -@property(nonatomic, assign) BOOL detachControllerView; -@property(nonatomic, copy) NSString* debugName; -@property(nonatomic, assign) NSInteger updateRevision; -@property(nonatomic, assign) NSInteger mountedRevision; -@property(nonatomic, copy) RCTDirectEventBlock onHostReady; -@property(nonatomic, assign) id hostReadyDelegate; - -- (void)layoutDetachedChildrenViewSubviewsIfNeeded; -- (BOOL)refreshDetachedChildrenHost; - -@end diff --git a/packages/react-native/ios/NativeScriptUIView.mm b/packages/react-native/ios/NativeScriptUIView.mm deleted file mode 100644 index 444b1094e..000000000 --- a/packages/react-native/ios/NativeScriptUIView.mm +++ /dev/null @@ -1,983 +0,0 @@ -#import "NativeScriptUIView.h" -#import "NativeScriptUIKitHost.h" -#import - -#if __has_include() -#import -#endif - -#if __has_include() && __has_include() -#import -#import -#endif - -static 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; - } - - id object = reinterpret_cast(static_cast(address)); - return object; -} - -static UIView* NativeScriptUIViewFromHandle(NSString* handle) { - id object = NativeScriptNSObjectFromHandle(handle); - if (object == nil || ![object isKindOfClass:UIView.class]) { - return nil; - } - - return static_cast(object); -} - -static UIViewController* NativeScriptUIViewControllerFromHandle(NSString* handle) { - id object = NativeScriptNSObjectFromHandle(handle); - if (object == nil || ![object isKindOfClass:UIViewController.class]) { - return nil; - } - - return static_cast(object); -} - -static NSString* NativeScriptHandleFromNSObject(id object) { - if (object == nil) { - return @""; - } - - return [NSString stringWithFormat:@"%p", object]; -} - -static BOOL NativeScriptChildrenViewHasVisibleChild(UIView* childrenView, UIView* sentinel) { - if (childrenView == nil) { - return NO; - } - - for (UIView* subview in childrenView.subviews) { - if (subview == sentinel || subview.hidden || subview.alpha <= 0.01) { - continue; - } - - return YES; - } - - return NO; -} - -static UIViewController* NativeScriptNearestViewController(UIView* view) { - UIResponder* responder = view; - while (responder != nil) { - responder = responder.nextResponder; - if ([responder isKindOfClass:UIViewController.class]) { - return static_cast(responder); - } - } - return nil; -} - -static BOOL NativeScriptViewIsDescendantOfView(UIView* view, UIView* ancestor) { - UIView* current = view; - while (current != nil) { - if (current == ancestor) { - return YES; - } - current = current.superview; - } - return NO; -} - -static BOOL NativeScriptViewHasGestureRecognizer(UIView* view, UIGestureRecognizer* recognizer) { - if (view == nil || recognizer == nil) { - return NO; - } - - for (UIGestureRecognizer* existingRecognizer in view.gestureRecognizers) { - if (existingRecognizer == recognizer) { - return YES; - } - } - - return NO; -} - -static UIView* NativeScriptGestureRecognizerAttachedView(id recognizer) { - if (recognizer == nil || ![recognizer isKindOfClass:UIGestureRecognizer.class]) { - return nil; - } - - return static_cast(recognizer).view; -} - -static UIGestureRecognizer* NativeScriptFindAncestorSurfaceTouchHandler(UIView* view) { -#if __has_include() - UIView* parent = view.superview; - NSUInteger depth = 0; - - while (parent != nil && depth < 32) { - for (UIGestureRecognizer* recognizer in parent.gestureRecognizers) { - if ([recognizer isKindOfClass:RCTSurfaceTouchHandler.class]) { - return recognizer; - } - } - - parent = parent.superview; - depth += 1; - } -#endif - - return nil; -} - -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 BOOL NativeScriptPointInsideTabBarHitArea(UITabBar* tabBar, UIWindow* window, - CGPoint windowPoint) { - if (tabBar == nil || tabBar.hidden || tabBar.alpha <= 0.01 || - !tabBar.userInteractionEnabled) { - return NO; - } - - CGPoint localPoint = [tabBar convertPoint:windowPoint fromView:window]; - return CGRectContainsPoint(NativeScriptEffectiveTabBarHitBounds(tabBar), localPoint); -} - -static UITabBar* NativeScriptVisibleTabBarAtPoint(UIView* root, UIWindow* window, - CGPoint windowPoint) { - if (root.hidden || root.alpha <= 0.01 || !root.userInteractionEnabled) { - return nil; - } - - 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 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; - } - - return fabs(childFrame.origin.x) < 1 && fabs(childFrame.origin.y) < 1 && - (childFrame.size.width <= 0 || fabs(childFrame.size.width - parentBounds.size.width) < 2); -} - -static void NativeScriptLayoutHostedSubviewChain(UIView* root, NSUInteger depth) { - if (root == nil || depth > 12 || [root isKindOfClass:UIScrollView.class]) { - return; - } - - const CGRect bounds = root.bounds; - for (UIView* subview in root.subviews) { - if (!NativeScriptSubviewShouldFillParent(root, subview)) { - continue; - } - - subview.frame = bounds; - subview.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; - [subview setNeedsLayout]; - [subview layoutIfNeeded]; - NativeScriptLayoutHostedSubviewChain(subview, depth + 1); - } -} - -@class NativeScriptUIView; - -static const void* NativeScriptDetachedChildrenOwnerKey = - &NativeScriptDetachedChildrenOwnerKey; - -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; - } - - objc_setAssociatedObject( - view, NativeScriptDetachedChildrenOwnerKey, owner, OBJC_ASSOCIATION_ASSIGN); -} - -@interface NativeScriptUIView () -- (void)attachDetachedChildrenTouchHandlerIfNeeded; -- (void)installDetachedChildrenTouchSentinelIfNeeded; -- (void)notifyHostReadyIfNeeded; -- (BOOL)refreshDetachedChildrenHost; -- (void)updateDetachedChildrenTouchHandlerOrigin; -@end - -@interface NativeScriptDetachedChildrenTouchSentinel : UIView -@property(nonatomic, assign) NativeScriptUIView* owner; -@end - -@implementation NativeScriptDetachedChildrenTouchSentinel - -- (void)didMoveToWindow { - [super didMoveToWindow]; - [self.owner refreshDetachedChildrenHost]; -} - -- (void)didMoveToSuperview { - [super didMoveToSuperview]; - [self.owner refreshDetachedChildrenHost]; -} - -- (void)layoutSubviews { - [super layoutSubviews]; - [self.owner refreshDetachedChildrenHost]; -} - -@end - -@implementation NativeScriptUIView { - UIView* _nativeView; - UIView* _childrenView; - UIViewController* _viewController; - id _detachedTouchHandler; - UIView* _detachedTouchHandlerView; - UIWindow* _detachedTouchHandlerWindow; - NativeScriptDetachedChildrenTouchSentinel* _detachedTouchSentinel; - NSInteger _hostMountRetryCount; - NSString* _lastHostReadyKey; -} - -- (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); - } - [_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]; -} - -- (void)setHostId:(NSString*)hostId { - if ((_hostId == hostId) || [_hostId isEqualToString:hostId]) { - return; - } - - NSString* previousHostId = [_hostId copy]; - if (previousHostId.length > 0) { - NativeScriptRunUIKitHostLifecycle(previousHostId, @"dispose"); - } - [previousHostId release]; - - [_hostId release]; - _hostId = [hostId copy]; - _hostMountRetryCount = 0; - [_lastHostReadyKey release]; - _lastHostReadyKey = nil; - [self mountUIKitHostIfNeeded]; - [self notifyHostReadyIfNeeded]; -} - -- (void)setHostReadyId:(NSString*)hostReadyId { - if ((_hostReadyId == hostReadyId) || [_hostReadyId isEqualToString:hostReadyId]) { - return; - } - - [_hostReadyId release]; - _hostReadyId = [hostReadyId copy]; - [_lastHostReadyKey release]; - _lastHostReadyKey = nil; - [self notifyHostReadyIfNeeded]; -} - -- (void)setOnHostReady:(RCTDirectEventBlock)onHostReady { - if (_onHostReady == onHostReady) { - return; - } - - [_onHostReady release]; - _onHostReady = [onHostReady copy]; - [self notifyHostReadyIfNeeded]; -} - -- (void)setNativeViewHandle:(NSString*)nativeViewHandle { - if ((_nativeViewHandle == nativeViewHandle) || - [_nativeViewHandle isEqualToString:nativeViewHandle]) { - return; - } - - [_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; - } - [self setNativeView:nativeView]; -} - -- (void)setChildrenViewHandle:(NSString*)childrenViewHandle { - if ((_childrenViewHandle == childrenViewHandle) || - [_childrenViewHandle isEqualToString:childrenViewHandle]) { - return; - } - - [_childrenViewHandle release]; - _childrenViewHandle = [childrenViewHandle copy]; - [self setChildrenView:NativeScriptUIViewFromHandle(_childrenViewHandle)]; -} - -- (void)setControllerHandle:(NSString*)controllerHandle { - if ((_controllerHandle == controllerHandle) || - [_controllerHandle isEqualToString:controllerHandle]) { - return; - } - - [_controllerHandle release]; - _controllerHandle = [controllerHandle copy]; - [self setViewController:NativeScriptUIViewControllerFromHandle(_controllerHandle)]; -} - -- (void)setDetachControllerView:(BOOL)detachControllerView { - if (_detachControllerView == detachControllerView) { - return; - } - - if (detachControllerView) { - [self detachViewController]; - if (_viewController != nil && _nativeView == _viewController.view) { - [self setNativeView:nil]; - } - } - - _detachControllerView = detachControllerView; - - if (!_detachControllerView && _viewController != nil) { - if (_nativeViewHandle.length == 0) { - [self setNativeView:_viewController.view]; - } - [self attachViewControllerIfPossible]; - } -} - -- (void)setDebugName:(NSString*)debugName { - if ((_debugName == debugName) || [_debugName isEqualToString:debugName]) { - return; - } - - [_debugName release]; - _debugName = [debugName copy]; -} - -- (void)setUpdateRevision:(NSInteger)updateRevision { - if (_updateRevision == updateRevision) { - return; - } - - _updateRevision = updateRevision; - if (_updateRevision > 0) { - [self runUIKitHostLifecycle:@"update"]; - } -} - -- (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 { - NSString* readyId = _hostReadyId.length > 0 ? _hostReadyId : _hostId; - if (readyId.length == 0) { - 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; -} - -- (void)notifyHostReadyIfNeeded { - const BOOL hasChildren = - NativeScriptChildrenViewHasVisibleChild(_childrenView, _detachedTouchSentinel); - if (!hasChildren) { - return; - } - - NSDictionary* event = [self hostReadyEventWithHasChildren:hasChildren]; - if (event == nil) { - return; - } - - NSString* key = [NSString - stringWithFormat:@"%@|%@|%@|%@|%@|%@", - event[@"hostReadyId"] ?: @"", - event[@"hostId"] ?: @"", - event[@"nativeViewHandle"] ?: @"", - event[@"childrenViewHandle"] ?: @"", - event[@"controllerHandle"] ?: @"", - [event[@"hasChildren"] boolValue] ? @"1" : @"0"]; - if ([_lastHostReadyKey isEqualToString:key]) { - return; - } - - [_lastHostReadyKey release]; - _lastHostReadyKey = [key copy]; - - if (_onHostReady != nil) { - _onHostReady(event); - } - if ([_hostReadyDelegate respondsToSelector:@selector(nativeScriptUIView:didHostReady:)]) { - [_hostReadyDelegate nativeScriptUIView:self didHostReady:event]; - } -} - -- (void)applyUIKitHostHandles:(NSDictionary*)handles { - if (handles == nil) { - return; - } - - NSString* nativeViewHandle = handles[@"nativeViewHandle"]; - NSString* childrenViewHandle = handles[@"childrenViewHandle"]; - NSString* controllerHandle = handles[@"controllerHandle"]; - - if (controllerHandle.length > 0) { - self.controllerHandle = controllerHandle; - } - if (nativeViewHandle.length > 0) { - self.nativeViewHandle = nativeViewHandle; - } - if (childrenViewHandle.length > 0) { - self.childrenViewHandle = childrenViewHandle; - } - [self notifyHostReadyIfNeeded]; -} - -- (void)mountUIKitHostIfNeeded { - if (_hostId.length == 0) { - return; - } - - NSDictionary* handles = NativeScriptCreateUIKitHost(_hostId); - if (handles != nil) { - _hostMountRetryCount = 0; - [self applyUIKitHostHandles:handles]; - return; - } - - if (_hostMountRetryCount >= 8) { - return; - } - - _hostMountRetryCount += 1; - NSString* retryHostId = [_hostId copy]; - dispatch_async(dispatch_get_main_queue(), ^{ - if (retryHostId.length > 0 && [self->_hostId isEqualToString:retryHostId]) { - [self mountUIKitHostIfNeeded]; - } - [retryHostId release]; - }); -} - -- (void)runUIKitHostLifecycle:(NSString*)phase { - if (_hostId.length == 0 || phase.length == 0) { - return; - } - - [self mountUIKitHostIfNeeded]; - [self applyUIKitHostHandles:NativeScriptRunUIKitHostLifecycle(_hostId, phase)]; -} - -- (void)setChildrenView:(UIView*)childrenView { - if (_childrenView == childrenView) { - return; - } - - [self detachDetachedChildrenTouchHandler]; - [_detachedTouchSentinel removeFromSuperview]; - [_detachedTouchSentinel release]; - _detachedTouchSentinel = nil; - if (NativeScriptDetachedChildrenOwner(_childrenView) == self) { - NativeScriptSetDetachedChildrenOwner(_childrenView, nil); - } - [_childrenView release]; - _childrenView = [childrenView retain]; - NativeScriptSetDetachedChildrenOwner(_childrenView, self); - [self moveReactSubviewsToChildrenView]; - [self installDetachedChildrenTouchSentinelIfNeeded]; - [self attachDetachedChildrenTouchHandlerIfNeeded]; - [self notifyHostReadyIfNeeded]; -} - -- (void)setNativeView:(UIView*)nativeView { - if (_nativeView == nativeView) { - return; - } - - [_nativeView removeFromSuperview]; - [_nativeView release]; - _nativeView = nil; - - if (nativeView == nil) { - return; - } - - _nativeView = [nativeView retain]; - [_nativeView removeFromSuperview]; - _nativeView.frame = self.bounds; - _nativeView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; - [super insertSubview:_nativeView atIndex:0]; - [self moveReactSubviewsToChildrenView]; - [self setNeedsLayout]; - [self notifyHostReadyIfNeeded]; -} - -- (void)setViewController:(UIViewController*)viewController { - if (_viewController == viewController) { - return; - } - - [self detachViewController]; - [_viewController release]; - _viewController = [viewController retain]; - if (_detachControllerView) { - if (_viewController != nil && _nativeView == _viewController.view) { - [self setNativeView:nil]; - } - return; - } - if (_nativeViewHandle.length == 0) { - [self setNativeView:_viewController.view]; - } - [self attachViewControllerIfPossible]; - [self notifyHostReadyIfNeeded]; -} - -- (void)attachViewControllerIfPossible { - if (_detachControllerView || _viewController == nil || - _viewController.parentViewController != nil || self.window == nil) { - return; - } - - UIViewController* parent = NativeScriptNearestViewController(self); - if (parent == nil || parent == _viewController) { - return; - } - - UIView* hostedViewToReinsert = nil; - NSUInteger hostedViewIndex = NSNotFound; - if (_nativeView.superview == self && - NativeScriptHostedViewContainsControllerView(_nativeView, _viewController)) { - hostedViewToReinsert = [_nativeView retain]; - hostedViewIndex = [self.subviews indexOfObject:hostedViewToReinsert]; - [hostedViewToReinsert removeFromSuperview]; - } - - const BOOL shouldForwardAppearance = - hostedViewToReinsert == nil && NativeScriptShouldForwardControllerAppearance(_viewController); - if (shouldForwardAppearance) { - [_viewController beginAppearanceTransition:YES animated:NO]; - } - - [parent addChildViewController:_viewController]; - if (hostedViewToReinsert != nil) { - NSUInteger targetIndex = - hostedViewIndex == NSNotFound ? 0 : MIN(hostedViewIndex, self.subviews.count); - [super insertSubview:hostedViewToReinsert atIndex:targetIndex]; - } - [_viewController didMoveToParentViewController:parent]; - - if (shouldForwardAppearance) { - [_viewController endAppearanceTransition]; - } - [hostedViewToReinsert release]; -} - -- (void)detachViewController { - if (_detachControllerView || _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)moveReactSubviewsToChildrenView { - if (_childrenView == nil) { - return; - } - - NSArray* subviews = [self.subviews copy]; - for (UIView* subview in subviews) { - if (subview == _nativeView || subview == _childrenView) { - continue; - } - [_childrenView addSubview:subview]; - } - [subviews release]; - [self layoutDetachedChildrenViewSubviewsIfNeeded]; - [self installDetachedChildrenTouchSentinelIfNeeded]; - [self attachDetachedChildrenTouchHandlerIfNeeded]; - [self notifyHostReadyIfNeeded]; -} - -- (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; - } - [super insertSubview:view atIndex:index]; - [self notifyHostReadyIfNeeded]; -} - -- (void)layoutDetachedChildrenViewSubviewsIfNeeded { - if (_childrenView == nil) { - return; - } - - const CGRect bounds = _childrenView.bounds; - for (UIView* subview in _childrenView.subviews) { - if (subview == _detachedTouchSentinel) { - subview.frame = CGRectZero; - continue; - } - - subview.frame = bounds; - subview.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; - [subview setNeedsLayout]; - [subview layoutIfNeeded]; - NativeScriptLayoutHostedSubviewChain(subview, 0); - } -} - -- (BOOL)refreshDetachedChildrenHost { - if (_childrenView == nil) { - return NO; - } - - [self layoutDetachedChildrenViewSubviewsIfNeeded]; - [self installDetachedChildrenTouchSentinelIfNeeded]; - [self attachDetachedChildrenTouchHandlerIfNeeded]; - [self updateDetachedChildrenTouchHandlerOrigin]; - [self notifyHostReadyIfNeeded]; - - return NativeScriptChildrenViewHasVisibleChild(_childrenView, _detachedTouchSentinel); -} - -- (void)installDetachedChildrenTouchSentinelIfNeeded { - if (_childrenView == nil || _detachedTouchSentinel != nil) { - 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 (_childrenView == nil) { - return; - } - - UIView* touchView = _childrenView; - touchView.userInteractionEnabled = YES; - if (NativeScriptFindAncestorSurfaceTouchHandler(touchView) != nil) { - [self detachDetachedChildrenTouchHandler]; - return; - } - - 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; - } - } - - 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]; - 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; -} - -- (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent*)event { - [self refreshDetachedChildrenHost]; - - 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]; - } - - if (hitView == nil || self.window == nil) { - return hitView; - } - - 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; - } - } - - return hitView; -} - -- (void)didMoveToWindow { - [super didMoveToWindow]; - [self mountUIKitHostIfNeeded]; - [self attachViewControllerIfPossible]; - [self attachDetachedChildrenTouchHandlerIfNeeded]; - [self updateDetachedChildrenTouchHandlerOrigin]; - [self notifyHostReadyIfNeeded]; -} - -- (void)layoutSubviews { - [super layoutSubviews]; - _nativeView.frame = self.bounds; - [self layoutDetachedChildrenViewSubviewsIfNeeded]; - [self installDetachedChildrenTouchSentinelIfNeeded]; - [self attachDetachedChildrenTouchHandlerIfNeeded]; - [self updateDetachedChildrenTouchHandlerOrigin]; - [self notifyHostReadyIfNeeded]; -} - -@end - -static BOOL NativeScriptRefreshUIKitHostSubviews(UIView* root, NSUInteger depth) { - if (root == nil || depth > 24) { - return NO; - } - - BOOL refreshed = NO; - if ([root isKindOfClass:NativeScriptUIView.class]) { - refreshed = [static_cast(root) refreshDetachedChildrenHost] || refreshed; - } - - NativeScriptUIView* detachedChildrenOwner = NativeScriptDetachedChildrenOwner(root); - if (detachedChildrenOwner != nil) { - refreshed = [detachedChildrenOwner refreshDetachedChildrenHost] || refreshed; - } - - if ([root isKindOfClass:NativeScriptDetachedChildrenTouchSentinel.class]) { - NativeScriptDetachedChildrenTouchSentinel* sentinel = - static_cast(root); - refreshed = [sentinel.owner refreshDetachedChildrenHost] || refreshed; - } - - for (UIView* subview in root.subviews) { - refreshed = NativeScriptRefreshUIKitHostSubviews(subview, depth + 1) || refreshed; - } - - return refreshed; -} - -BOOL NativeScriptRefreshUIKitHostView(NSString* viewHandle) { - if (![NSThread isMainThread]) { - return NO; - } - - UIView* view = NativeScriptUIViewFromHandle(viewHandle); - if (view == nil) { - return NO; - } - - return NativeScriptRefreshUIKitHostSubviews(view, 0); -} diff --git a/packages/react-native/ios/NativeScriptUIViewManager.mm b/packages/react-native/ios/NativeScriptUIViewManager.mm deleted file mode 100644 index 9de511f65..000000000 --- a/packages/react-native/ios/NativeScriptUIViewManager.mm +++ /dev/null @@ -1,27 +0,0 @@ -#import - -#import "NativeScriptUIView.h" - -@interface NativeScriptUIViewManager : RCTViewManager -@end - -@implementation NativeScriptUIViewManager - -RCT_EXPORT_MODULE(NativeScriptUIView) - -- (UIView*)view { - return [[[NativeScriptUIView alloc] initWithFrame:CGRectZero] autorelease]; -} - -RCT_EXPORT_VIEW_PROPERTY(nativeViewHandle, NSString) -RCT_EXPORT_VIEW_PROPERTY(childrenViewHandle, NSString) -RCT_EXPORT_VIEW_PROPERTY(controllerHandle, NSString) -RCT_EXPORT_VIEW_PROPERTY(detachControllerView, BOOL) -RCT_EXPORT_VIEW_PROPERTY(debugName, NSString) -RCT_EXPORT_VIEW_PROPERTY(hostId, NSString) -RCT_EXPORT_VIEW_PROPERTY(hostReadyId, NSString) -RCT_EXPORT_VIEW_PROPERTY(updateRevision, NSInteger) -RCT_EXPORT_VIEW_PROPERTY(mountedRevision, NSInteger) -RCT_EXPORT_VIEW_PROPERTY(onHostReady, RCTDirectEventBlock) - -@end diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 0913beac7..67993fe67 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -63,9 +63,6 @@ "javaPackageName": "org.nativescript.nativeapi" }, "ios": { - "componentProvider": { - "NativeScriptUIView": "NativeScriptUIViewComponentView" - }, "modulesProvider": { "NativeScriptNativeApi": "NativeScriptNativeApiModuleProvider" } diff --git a/packages/react-native/plugin/babel-plugin.js b/packages/react-native/plugin/babel-plugin.js index 9e80f8b98..e9b9a72b0 100644 --- a/packages/react-native/plugin/babel-plugin.js +++ b/packages/react-native/plugin/babel-plugin.js @@ -1,17 +1,22 @@ const PACKAGE_NAME = '@nativescript/react-native'; -const UIKIT_DEFINITION_CALLEES = new Set([ - 'defineUIKitContainer', - 'defineUIKitView', - 'defineUIViewController', -]); -const UIKIT_WORKLET_CALLBACKS = new Set([ +// D1 (DECISIONS.md): the old `defineUIKitView`/`defineUIKitContainer`/ +// `defineUIViewController` surface is retired; this plugin only +// auto-workletizes `defineNativeComponent` specs now. +const NATIVE_COMPONENT_DEFINITION_CALLEES = new Set(['defineNativeComponent']); +const NATIVE_COMPONENT_WORKLET_CALLBACKS = new Set([ 'create', - 'createController', - 'childrenView', - 'dispose', - 'mounted', - 'update', + 'updateProps', + 'mountChildComponentView', + 'unmountChildComponentView', + 'mountingTransactionWillMount', + 'mountingTransactionDidMount', + 'updateLayoutMetrics', + 'finalizeUpdates', + 'prepareForRecycle', ]); +// The one nested object in a defineNativeComponent spec whose OWN properties +// (not the object itself) are hooks; `commands: { doThing(ctx, args) {} }`. +const NATIVE_COMPONENT_NESTED_CALLBACK_CONTAINERS = new Set(['commands']); function isDirectiveFunction(path) { const body = path.node.body; @@ -90,7 +95,7 @@ function findNativeScriptIdentifier(programPath, t) { function collectNativeScriptBindings(programPath, t) { const nativeScriptIdentifiers = new Set(); - const uikitDefinitionIdentifiers = new Set(); + const nativeComponentDefinitionIdentifiers = new Set(); for (const statement of programPath.get('body')) { if (statement.isImportDeclaration()) { @@ -108,8 +113,8 @@ function collectNativeScriptBindings(programPath, t) { const importedName = t.isIdentifier(imported) ? imported.name : imported.value; - if (UIKIT_DEFINITION_CALLEES.has(importedName)) { - uikitDefinitionIdentifiers.add(specifier.local.name); + if (NATIVE_COMPONENT_DEFINITION_CALLEES.has(importedName)) { + nativeComponentDefinitionIdentifiers.add(specifier.local.name); } } } @@ -147,10 +152,10 @@ function collectNativeScriptBindings(programPath, t) { const value = property.value; const keyName = t.isIdentifier(key) ? key.name : key.value; if ( - UIKIT_DEFINITION_CALLEES.has(keyName) && + NATIVE_COMPONENT_DEFINITION_CALLEES.has(keyName) && t.isIdentifier(value) ) { - uikitDefinitionIdentifiers.add(value.name); + nativeComponentDefinitionIdentifiers.add(value.name); } } } @@ -159,7 +164,7 @@ function collectNativeScriptBindings(programPath, t) { return { nativeScriptIdentifiers, - uikitDefinitionIdentifiers, + nativeComponentDefinitionIdentifiers, }; } @@ -212,27 +217,6 @@ function ensureNativeScriptIdentifier(programPath, state, t) { return identifier.name; } -function isUIKitDefinitionCall(path, state, t) { - const callee = path.node.callee; - if ( - t.isIdentifier(callee) && - state.uikitDefinitionIdentifiers?.has(callee.name) - ) { - return true; - } - if ( - t.isMemberExpression(callee) && - !callee.computed && - t.isIdentifier(callee.object) && - t.isIdentifier(callee.property) && - state.nativeScriptIdentifiers?.has(callee.object.name) && - UIKIT_DEFINITION_CALLEES.has(callee.property.name) - ) { - return true; - } - return false; -} - function propertyKeyName(property, t) { const key = property.node.key; if (t.isIdentifier(key)) { @@ -263,8 +247,42 @@ function ensureWorkletDirective(functionNode, t) { ]; } -function workletizeUIKitDefinitionCallbacks(path, state, t) { - if (!isUIKitDefinitionCall(path, state, t)) { +function isNativeComponentDefinitionCall(path, state, t) { + const callee = path.node.callee; + if ( + t.isIdentifier(callee) && + state.nativeComponentDefinitionIdentifiers?.has(callee.name) + ) { + return true; + } + if ( + t.isMemberExpression(callee) && + !callee.computed && + t.isIdentifier(callee.object) && + t.isIdentifier(callee.property) && + state.nativeScriptIdentifiers?.has(callee.object.name) && + NATIVE_COMPONENT_DEFINITION_CALLEES.has(callee.property.name) + ) { + return true; + } + return false; +} + +function ensureWorkletDirectiveOnProperty(property, t) { + if (property.isObjectMethod()) { + ensureWorkletDirective(property.node, t); + } else if (property.isObjectProperty()) { + const value = property.get('value'); + if (value.isFunctionExpression() || value.isArrowFunctionExpression()) { + ensureWorkletDirective(value.node, t); + } + } +} + +// Auto-workletizes a `defineNativeComponent` spec's Fabric-named hooks -- +// PLUS one level of nesting for `commands: {...}`. +function workletizeNativeComponentDefinitionCallbacks(path, state, t) { + if (!isNativeComponentDefinitionCall(path, state, t)) { return; } @@ -278,15 +296,22 @@ function workletizeUIKitDefinitionCallbacks(path, state, t) { continue; } const keyName = propertyKeyName(property, t); - if (!UIKIT_WORKLET_CALLBACKS.has(keyName)) { + if (NATIVE_COMPONENT_WORKLET_CALLBACKS.has(keyName)) { + ensureWorkletDirectiveOnProperty(property, t); continue; } - if (property.isObjectMethod()) { - ensureWorkletDirective(property.node, t); - } else if (property.isObjectProperty()) { - const value = property.get('value'); - if (value.isFunctionExpression() || value.isArrowFunctionExpression()) { - ensureWorkletDirective(value.node, t); + if ( + NATIVE_COMPONENT_NESTED_CALLBACK_CONTAINERS.has(keyName) && + property.isObjectProperty() + ) { + const container = property.get('value'); + if (!container.isObjectExpression()) { + continue; + } + for (const commandProperty of container.get('properties')) { + if (!commandProperty.isSpreadElement()) { + ensureWorkletDirectiveOnProperty(commandProperty, t); + } } } } @@ -314,7 +339,7 @@ function wrapDirectiveFunction(path, state, t) { } if (policy === 'ui') { throw path.buildCodeFrameError( - 'NativeScript "use ui" callbacks are not supported in React Native. Use a Worklets "worklet" callback with NativeScript.runOnUI().', + 'NativeScript "use ui" callbacks are not supported in React Native. Use a Worklets "worklet" callback with NativeScript.scheduleOnUI().', ); } @@ -340,11 +365,11 @@ module.exports = function nativeScriptReactNativeBabelPlugin({types: t}) { Program(path, state) { const bindings = collectNativeScriptBindings(path, t); state.nativeScriptIdentifiers = bindings.nativeScriptIdentifiers; - state.uikitDefinitionIdentifiers = bindings.uikitDefinitionIdentifiers; + state.nativeComponentDefinitionIdentifiers = bindings.nativeComponentDefinitionIdentifiers; state.nativeScriptIdentifier = findNativeScriptIdentifier(path, t); }, CallExpression(path, state) { - workletizeUIKitDefinitionCallbacks(path, state, t); + workletizeNativeComponentDefinitionCallbacks(path, state, t); }, ArrowFunctionExpression(path, state) { wrapDirectiveFunction(path, state, t); diff --git a/packages/react-native/src/NativeScriptNativeApi.ts b/packages/react-native/src/NativeScriptNativeApi.ts index 4cb54f02e..5168d0cd5 100644 --- a/packages/react-native/src/NativeScriptNativeApi.ts +++ b/packages/react-native/src/NativeScriptNativeApi.ts @@ -4,14 +4,51 @@ import {TurboModuleRegistry} from 'react-native'; export interface Spec extends TurboModule { readonly install: (metadataPath: string) => boolean; - readonly installWorkletRuntime: ( + // Holder handshake (ARCHITECTURE.md §3.5, §7.1): installs the NativeScript + // ObjC bridge onto the Worklets UI runtime, the same StableApi.h path + // Reanimated uses. Called once from the RN JS thread at bootstrap (a + // one-time exception to the "only enter the UI runtime from main" rule, + // same as Worklets' own bootstrap use of runOnUISync; see §3.3/§9.2). + // `schedulerHolder` (M1) is the UIScheduler holder handshake alongside the + // WorkletRuntime one; lets native route off-main async entries through + // the sanctioned `worklets::scheduleOnUI` instead of a raw dispatch_async. + readonly installUIRuntime: ( runtimeHolder: UnsafeObject, + schedulerHolder: UnsafeObject, metadataPath: string, ) => boolean; readonly isInstalled: () => boolean; readonly defaultMetadataPath: () => string; readonly getRuntimeBackend: () => string; readonly __writeTestMarker: (content: string) => boolean; + // Test-only companion to __writeTestMarker: symmetric read-back of the + // same smoke-marker file. + readonly __readTestMarker: () => string; + // JOB2 dev-reload test: a marker file SEPARATE from the smoke marker + // above (native's own install-sequence "stage=..." writes to the smoke + // marker on every reload would otherwise clobber a phase flag stored + // there before the reloaded JS ever reads it back; confirmed on-sim as + // an infinite reload loop). Used to detect, from a freshly-reloaded JS + // VM, whether a previous phase already wrote it. + readonly __writeReloadPhaseMarker: (content: string) => boolean; + readonly __readReloadPhaseMarker: () => string; + + // defineNativeComponent's native registration step (ARCHITECTURE.md §5.2 + // steps 1-2): extracts a worklets Serializable from `spec` synchronously + // on the JS thread (no UI-runtime entry, so no ordering race with first + // mount), stores it keyed by `name` alongside `hookMask` (a bitwise-OR of + // NativeScriptComponentHook from NativeScriptFabricGateway.h), and + // registers the flavored Fabric class. + // `shouldBeRecycled`: tri-state as a number (codegen-friendly, no optional + // booleans); -1 means the spec never set the flag (leave RN's own + // `shouldBeRecycled: true` default alone), 0/1 are false/true. Wired onto + // a per-flavor `+shouldBeRecycled` class method (M1 review §2/(c)). + readonly registerComponent: ( + name: string, + spec: UnsafeObject, + hookMask: number, + shouldBeRecycled: number, + ) => boolean; } export default TurboModuleRegistry.getEnforcing('NativeScriptNativeApi'); diff --git a/packages/react-native/src/NativeScriptUIViewNativeComponent.ts b/packages/react-native/src/NativeScriptUIViewNativeComponent.ts deleted file mode 100644 index e6b930c63..000000000 --- a/packages/react-native/src/NativeScriptUIViewNativeComponent.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type {HostComponent, ViewProps} from 'react-native'; -import type { - DirectEventHandler, - Int32, -} from 'react-native/Libraries/Types/CodegenTypes'; -import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; - -export type HostReadyEvent = { - hostReadyId: string; - hostId: string; - nativeViewHandle: string; - childrenViewHandle: string; - controllerHandle: string; - hasChildren: boolean; -}; - -export interface NativeProps extends ViewProps { - hostId?: string; - hostReadyId?: string; - nativeViewHandle?: string; - childrenViewHandle?: string; - controllerHandle?: string; - detachControllerView?: boolean; - debugName?: string; - updateRevision?: Int32; - mountedRevision?: Int32; - onHostReady?: DirectEventHandler; -} - -export default codegenNativeComponent( - 'NativeScriptUIView', -) as HostComponent; diff --git a/packages/react-native/src/defineNativeComponent.ts b/packages/react-native/src/defineNativeComponent.ts new file mode 100644 index 000000000..37aed60b9 --- /dev/null +++ b/packages/react-native/src/defineNativeComponent.ts @@ -0,0 +1,312 @@ +/** Defines a Fabric component at runtime from a TypeScript hook object. */ +// React Native uses this registry under `codegenNativeComponent`. It is also +// the available registration path for component names created at runtime. +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore; no .d.ts shipped for this RN-internal module. +import * as NativeComponentRegistry from "react-native/Libraries/NativeComponent/NativeComponentRegistry"; +import { findNodeHandle } from "react-native"; +import type { HostComponent, ViewProps } from "react-native"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore; this RN-internal module has no .d.ts file. The bridgeless +// architecture dispatches commands through FabricUIManager. +import { getFabricUIManager } from "react-native/Libraries/ReactNative/FabricUIManager"; + +import NativeScriptNativeApi from "./NativeScriptNativeApi"; +import { + ensureDispatcherInstalled, + NativeScriptComponentHook, + type MountingTransaction, + type NSComponentContext, +} from "./ui/dispatcher"; + +export type { NSComponentContext, MountingTransaction, TransactionMutation } from "./ui/dispatcher"; + +declare const require: (id: string) => any; + +// Load Worklets only when a caller defines a native component. The rest of +// the package can run without react-native-worklets. +let cachedCreateSerializable: ((value: unknown) => object) | undefined; +function requireCreateSerializable(): (value: unknown) => object { + if (!cachedCreateSerializable) { + const worklets = require("react-native-worklets"); + if (typeof worklets?.createSerializable !== "function") { + throw new Error( + "defineNativeComponent requires react-native-worklets (createSerializable was not found)", + ); + } + cachedCreateSerializable = worklets.createSerializable; + } + return cachedCreateSerializable; +} + +let cachedIsWorkletFunction: ((value: unknown) => boolean) | undefined; +function requireIsWorkletFunction(): (value: unknown) => boolean { + if (!cachedIsWorkletFunction) { + cachedIsWorkletFunction = require("react-native-worklets")?.isWorkletFunction; + } + return cachedIsWorkletFunction ?? (() => false); +} + +// Validate worklets when the component is defined so a missing directive does +// not fail during the first mount. +const NATIVE_COMPONENT_HOOK_NAMES = [ + "create", + "updateProps", + "mountChildComponentView", + "unmountChildComponentView", + "mountingTransactionWillMount", + "mountingTransactionDidMount", + "updateLayoutMetrics", + "finalizeUpdates", + "prepareForRecycle", +] as const; + +// Worklets compiles function declarations into non-hoisted const bindings. +// Walk nested closures and reject an undefined capture before the hook runs. +// Mutually dependent helpers should live on a stable object and call each +// other through property lookup. +function findDeadClosureCapture( + fn: { __closure?: Record }, + path: string[], + visited: Set, +): { path: string[]; key: string } | undefined { + const closure = fn.__closure; + if (!closure || typeof closure !== "object") { + return undefined; + } + for (const key of Object.keys(closure)) { + const captured = closure[key]; + if (captured === undefined) { + return { path, key }; + } + if (typeof captured === "function" && "__closure" in captured) { + if (visited.has(captured)) { + continue; // This shared reference was already checked. + } + visited.add(captured); + const nested = findDeadClosureCapture( + captured as { __closure?: Record }, + [...path, key], + visited, + ); + if (nested) { + return nested; + } + } + } + return undefined; +} + +function validateHookIsWorklet(spec: Record, hookLabel: string, fn: unknown): void { + const isWorkletFunction = requireIsWorkletFunction(); + if (typeof fn !== "function" || !isWorkletFunction(fn)) { + throw new Error( + `defineNativeComponent("${String(spec.name)}"): "${hookLabel}" is missing a 'worklet' directive ` + + `(or the Worklets Babel plugin isn't running on this file). Every defineNativeComponent hook, ` + + `including entries inside "commands", runs on the UI runtime and must start with 'worklet'.`, + ); + } + const visited = new Set([fn]); + const dead = findDeadClosureCapture(fn as { __closure?: Record }, [hookLabel], visited); + if (dead) { + const chain = dead.path.join(" -> captures -> "); + throw new Error( + `defineNativeComponent("${String(spec.name)}"): "${chain} -> captures -> ${dead.key}" is undefined. ` + + `A worklet captured "${dead.key}" before it initialized. Worklet declarations are not hoisted. ` + + `Move mutually dependent helpers onto a stable object, such as globalThis.__moduleHelpers, ` + + `and call them through property lookup.`, + ); + } +} + +function validateSpecWorklets(spec: Record): void { + for (const hook of NATIVE_COMPONENT_HOOK_NAMES) { + if (spec[hook] !== undefined) { + validateHookIsWorklet(spec, hook, spec[hook]); + } + } + const commands = spec.commands as Record | undefined; + if (commands && typeof commands === "object") { + for (const commandName of Object.keys(commands)) { + validateHookIsWorklet(spec, `commands.${commandName}`, commands[commandName]); + } + } +} + +type EventPayloads = Record; + +type ChildRef = { tag: number; view: unknown; instance?: Instance }; + +type FrameMetrics = { x: number; y: number; width: number; height: number }; + +export type NativeComponentSpec< + Props extends object = object, + Events extends EventPayloads = EventPayloads, + Instance extends object = Record, +> = { + /** The Fabric component name. */ + name: string; + /** Prop defaults. Keys become validAttributes. */ + props?: Props; + /** Direct event names, typed by Events. */ + events?: (keyof Events & string)[]; + /** + * When false, Fabric tears this component down through `-invalidate` + * instead of the recycle pool. `prepareForRecycle` runs on both paths. + */ + shouldBeRecycled?: boolean; + + // Every hook below is a worklet on the main thread. + create?(ctx: NSComponentContext): unknown | void; + /** + * Fabric sends only props that changed in the current commit. Merge these + * partial values into `ctx.instance` instead of replacing stored state. + */ + updateProps?(ctx: NSComponentContext, next: Partial, prev: Partial): void; + /** + * Declaring this hook replaces Fabric's default child mounting behavior. + */ + mountChildComponentView?(ctx: NSComponentContext, child: ChildRef, index: number): void; + /** Symmetric with `mountChildComponentView`; declaring this hook means + * `super` is never called here either. */ + unmountChildComponentView?(ctx: NSComponentContext, child: ChildRef, index: number): void; + mountingTransactionWillMount?(ctx: NSComponentContext, txn: MountingTransaction): void; + mountingTransactionDidMount?(ctx: NSComponentContext, txn: MountingTransaction): void; + /** Return false to keep the current frame instead of Fabric's frame. */ + updateLayoutMetrics?(ctx: NSComponentContext, next: FrameMetrics, prev: FrameMetrics): boolean; + finalizeUpdates?(ctx: NSComponentContext, mask: number): void; + /** `viaInvalidate` identifies the `-invalidate` teardown path. */ + prepareForRecycle?(ctx: NSComponentContext, viaInvalidate: boolean): void; + /** Invoked from JS via `dispatchNativeComponentCommand(ref.current, name, args)`. */ + commands?: Record, args: unknown[]) => void>; +}; + +// Event keys are the JSX prop names supplied by the component definition. +type DirectEventHandlers = { + [K in keyof Events & string]?: (event: { nativeEvent: Events[K] }) => void; +}; + +export type NativeComponentProps = ViewProps & + Props & + DirectEventHandlers; + +function computeHookMask(spec: NativeComponentSpec): number { + let mask = 0; + if (spec.updateProps) mask |= NativeScriptComponentHook.UpdateProps; + if (spec.mountChildComponentView) mask |= NativeScriptComponentHook.MountChild; + if (spec.unmountChildComponentView) mask |= NativeScriptComponentHook.UnmountChild; + if (spec.mountingTransactionWillMount) mask |= NativeScriptComponentHook.WillMount; + if (spec.mountingTransactionDidMount) mask |= NativeScriptComponentHook.DidMount; + if (spec.updateLayoutMetrics) mask |= NativeScriptComponentHook.UpdateLayoutMetrics; + if (spec.finalizeUpdates) mask |= NativeScriptComponentHook.FinalizeUpdates; + if (spec.prepareForRecycle) mask |= NativeScriptComponentHook.PrepareForRecycle; + if (spec.commands && Object.keys(spec.commands).length > 0) mask |= NativeScriptComponentHook.Commands; + return mask; +} + +function eventNameToRegistrationName(name: string): string { + // React Native maps an `onSomething` prop to a `topSomething` direct event. + if (!/^on[A-Z]/.test(name)) { + throw new Error( + `defineNativeComponent: event name "${name}" must start with "on" followed by an uppercase letter, such as "onSomething".`, + ); + } + return `top${name.slice(2)}`; +} + +function buildViewConfig(spec: NativeComponentSpec) { + // Do not add `style` here. The base view config provides React Native's + // style descriptor. Replacing it with `true` prevents Yoga properties from + // reaching the shadow node. + const validAttributes: Record = {}; + for (const key of Object.keys(spec.props ?? {})) { + validAttributes[key] = true; + } + + // The map key is Fabric's internal event name. `registrationName` is the + // JSX prop name. + const directEventTypes: Record = {}; + for (const eventName of spec.events ?? []) { + directEventTypes[eventNameToRegistrationName(eventName)] = { registrationName: eventName }; + } + + return { + uiViewClassName: spec.name, + validAttributes, + directEventTypes, + bubblingEventTypes: {}, + }; +} + +/** + * Registers `spec` as a Fabric-native component and returns a typed React + * host component (``). + * + * The function serializes the worklet handlers, registers the native Fabric + * class, and builds the JS view config before returning the component. + */ +export function defineNativeComponent< + Props extends object = object, + Events extends EventPayloads = EventPayloads, + Instance extends object = Record, +>(spec: NativeComponentSpec): HostComponent> { + if (!spec || typeof spec.name !== "string" || spec.name.length === 0) { + throw new Error("defineNativeComponent requires a non-empty `name`"); + } + + // Start installing the dispatcher before native registration completes. + ensureDispatcherInstalled(); + + validateSpecWorklets(spec as unknown as Record); + + const hookMask = computeHookMask(spec as NativeComponentSpec); + // Native registration expects the SerializableJSRef marker produced by + // Worklets' `createSerializable`. + const serializableSpec = requireCreateSerializable()(spec); + const shouldBeRecycledTriState = + spec.shouldBeRecycled === undefined ? -1 : spec.shouldBeRecycled ? 1 : 0; + const registered = NativeScriptNativeApi.registerComponent( + spec.name, + serializableSpec as object, + hookMask, + shouldBeRecycledTriState, + ); + if (!registered) { + throw new Error(`defineNativeComponent("${spec.name}") failed to register with NativeScript`); + } + + const viewConfig = buildViewConfig(spec as NativeComponentSpec); + return NativeComponentRegistry.get(spec.name, () => viewConfig) as HostComponent< + NativeComponentProps + >; +} + +/** Dispatches a Fabric command to a mounted component. */ +export function dispatchNativeComponentCommand( + componentRef: unknown, + commandName: string, + args: unknown[] = [], +): void { + const handle = findNodeHandle(componentRef as never); + if (handle == null) { + throw new Error( + `dispatchNativeComponentCommand("${commandName}"): findNodeHandle(componentRef) returned null. ` + + "pass a mounted defineNativeComponent instance's ref.current.", + ); + } + const fabricUIManager = getFabricUIManager(); + if (fabricUIManager == null) { + throw new Error(`dispatchNativeComponentCommand("${commandName}"): not running on Fabric`); + } + const shadowNode = fabricUIManager.findShadowNodeByTag_DEPRECATED(handle); + if (shadowNode == null) { + throw new Error( + `dispatchNativeComponentCommand("${commandName}"): no shadow node for tag ${handle} (unmounted?)`, + ); + } + fabricUIManager.dispatchCommand(shadowNode, commandName, args); +} + +// A component may wrap any UIKit view, so this layer cannot provide one +// static view type. +export type NativeView = unknown; diff --git a/packages/react-native/src/index.d.ts b/packages/react-native/src/index.d.ts index 3e15e684d..ec1ea3186 100644 --- a/packages/react-native/src/index.d.ts +++ b/packages/react-native/src/index.d.ts @@ -1,11 +1,17 @@ /// -import type { - ForwardRefExoticComponent, - PropsWithoutRef, - RefAttributes, -} from "react"; -import type { ViewProps } from "react-native"; +export { + defineNativeComponent, + dispatchNativeComponentCommand, +} from "./defineNativeComponent"; +export type { + NativeComponentSpec, + NativeComponentProps, + NativeView, + MountingTransaction, + TransactionMutation, +} from "./defineNativeComponent"; +export type { NSComponentContext } from "./ui/dispatcher"; export type NativeApiHost = { runtime?: string; @@ -46,6 +52,7 @@ export type InstallOptions = { export type NativeScriptWorklets = { getUIRuntimeHolder: () => object; + getUISchedulerHolder?: () => object; isWorkletFunction: (value: unknown) => boolean; runOnUIAsync: ( callback: (...args: Args) => ReturnValue | Promise, @@ -53,72 +60,6 @@ export type NativeScriptWorklets = { ) => 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; }; @@ -155,121 +96,6 @@ export type CreateDelegateOptions = { }; }; -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; @@ -280,7 +106,7 @@ export function installWorklets( worklets?: NativeScriptWorklets, metadataPath?: string, ): boolean; -export function runOnUI( +export function scheduleOnUI( callback: (...args: Args) => ReturnValue | Promise, ...args: Args ): Promise; @@ -300,8 +126,6 @@ export function eventBridge any>( 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, @@ -321,22 +145,6 @@ export function createDelegate( 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; @@ -344,9 +152,8 @@ declare const NativeScript: { installGlobals: typeof installGlobals; isInstalled: typeof isInstalled; defaultMetadataPath: typeof defaultMetadataPath; - defineUIKitContainer: typeof defineUIKitContainer; - defineUIKitView: typeof defineUIKitView; - defineUIViewController: typeof defineUIViewController; + defineNativeComponent: typeof import("./defineNativeComponent").defineNativeComponent; + dispatchNativeComponentCommand: typeof import("./defineNativeComponent").dispatchNativeComponentCommand; getRuntimeBackend: typeof getRuntimeBackend; installWorklets: typeof installWorklets; assertUIKitThread: typeof assertUIKitThread; @@ -363,8 +170,7 @@ declare const NativeScript: { loadFramework: typeof loadFramework; release: typeof release; retain: typeof retain; - refreshUIKitHostView: typeof refreshUIKitHostView; - runOnUI: typeof runOnUI; + scheduleOnUI: typeof scheduleOnUI; runtimeInvoker: typeof runtimeInvoker; uiInvoker: typeof uiInvoker; warnIfNotUIKitThread: typeof warnIfNotUIKitThread; diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index c7b9a2bb5..e50017c37 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -13,7 +13,17 @@ import type { } from "react"; import type { ViewProps } from "react-native"; import NativeScriptNativeApi from "./NativeScriptNativeApi"; -import NativeScriptUIViewNativeComponent from "./NativeScriptUIViewNativeComponent"; +import { defineNativeComponent, dispatchNativeComponentCommand } from "./defineNativeComponent"; + +export { defineNativeComponent, dispatchNativeComponentCommand } from "./defineNativeComponent"; +export type { + NativeComponentSpec, + NativeComponentProps, + NativeView, + MountingTransaction, + TransactionMutation, +} from "./defineNativeComponent"; +export type { NSComponentContext } from "./ui/dispatcher"; declare const require: (id: string) => any; @@ -47,6 +57,10 @@ export type InstallOptions = { export type NativeScriptWorklets = { getUIRuntimeHolder: () => object; + // M1 (ARCHITECTURE.md §3.3/§7.1): the UIScheduler holder handshake, + // installed alongside the WorkletRuntime one so native can route off-main + // async entries through the sanctioned `worklets::scheduleOnUI`. + getUISchedulerHolder?: () => object; isWorkletFunction: (value: unknown) => boolean; runOnUIAsync: ( callback: (...args: Args) => ReturnValue | Promise, @@ -54,75 +68,6 @@ export type NativeScriptWorklets = { ) => 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; -}; - -type UIKitCreateArgument = UIKitViewContext & - Readonly; - export type NativeScriptImageLoadOptions = { template?: boolean; }; @@ -132,103 +77,6 @@ export type NativeScriptImageLoadCallback = ( error: Error | null, ) => void; -export type UIKitDisposeResult = - | void - | { - removeHostView?: boolean; - }; - -export type UIKitViewDefinition = { - name?: string; - debugName?: string; - displayName?: string; - layout?: UIKitLayoutOptions; - create: (ctx: UIKitCreateArgument) => 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: UIKitCreateArgument, - ) => 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: UIKitCreateArgument) => Controller; - hostView?: (controller: Controller) => unknown; - childrenView?: (controller: Controller) => unknown; -}; const nativeApiGlobalName = "__nativeScriptNativeApi"; const nativeApiGlobalCacheName = "__nativeScriptNativeApiGlobalCache"; @@ -318,17 +166,49 @@ function cacheNativeGlobal(name: string, value: unknown): void { nativeApiGlobalCache()[name] = value; } +// M1 review §3/#3 (fix-list item 3, the ACTUAL root cause; see the +// dedicated ctx.createDelegate report section): `createDelegate` (a real +// worklet, "worklet" directive present) closes over the MODULE-LEVEL +// `defaultNativeRetainer` singleton below and calls its `.retain`/`.release` +// methods. Neither this object's methods NOR the top-level `retain`/ +// `release`/`createRetainer` wrappers below carried a `'worklet'` directive +//; so the FIRST time `createDelegate` actually ran on the UI runtime and +// materialized its closure, `defaultNativeRetainer` (a plain object) got +// walked by worklets' closure-cloning and each of ITS non-worklet methods +// was individually wrapped as a "remote function" bound to the RN JS +// thread; calling `defaultNativeRetainer.retain(delegate)` on the UI +// runtime then hit exactly `[Worklets] Tried to synchronously call a Remote +// Function. Called "retain" on the UI Runtime.`, BEFORE any delegate method +// ever ran, which is exactly the symptom this fix list flagged as +// "unverified/likely a worklet-shape gap, not a NativeScript.extend() bug". +// Bisected on-sim (scripts/test_react_native_turbomodule_m1.sh's +// DelegateBisectProbe): a `NSObject.extend()` call with methods that do NOT +// close over `ctx` fails identically on an UNRELATED non-worklet capture +// (`NativeScript.getClass`), confirming the mechanism generalizes: ANY +// non-'worklet' function reachable from a worklet's closure breaks the same +// way, not something specific to `ctx`/`.extend()`. function createNativeRetainer(): NativeRetainer { + "worklet"; const retained: unknown[] = []; return { + // NOT 'worklet'-directived: react-native-worklets' Babel plugin does not + // support the directive on an object GETTER (confirmed on-sim; it + // throws `Unexpected token, expected "(" ` while re-parsing the + // extracted snippet). `.size` is a diagnostic convenience, not on + // `createDelegate`'s call path, so it stays JS-thread-only for now + // rather than fighting the plugin; reading it from a worklet will hit + // the same "Remote Function" guard as everything else in this file that + // isn't marked; a known, narrow, documented gap. get size() { return retained.length; }, retain(value: T): T { + "worklet"; retained.push(value); return value; }, release(value?: unknown) { + "worklet"; if (arguments.length === 0) { retained.length = 0; return; @@ -340,6 +220,7 @@ function createNativeRetainer(): NativeRetainer { } }, dispose() { + "worklet"; retained.length = 0; }, }; @@ -348,14 +229,17 @@ function createNativeRetainer(): NativeRetainer { const defaultNativeRetainer = createNativeRetainer(); export function createRetainer(): NativeRetainer { + "worklet"; return createNativeRetainer(); } export function retain(value: T): T { + "worklet"; return defaultNativeRetainer.retain(value); } export function release(value?: unknown): void { + "worklet"; if (arguments.length === 0) { defaultNativeRetainer.dispose(); return; @@ -363,152 +247,6 @@ export function release(value?: unknown): void { defaultNativeRetainer.release(value); } -const hostViewPropNames = new Set([ - "accessible", - "accessibilityActions", - "accessibilityElementsHidden", - "accessibilityHint", - "accessibilityIgnoresInvertColors", - "accessibilityLabel", - "accessibilityLanguage", - "accessibilityLiveRegion", - "accessibilityRole", - "accessibilityState", - "accessibilityValue", - "accessibilityViewIsModal", - "children", - "collapsable", - "focusable", - "hitSlop", - "id", - "importantForAccessibility", - "nativeID", - "needsOffscreenAlphaCompositing", - "onAccessibilityAction", - "onAccessibilityEscape", - "onAccessibilityTap", - "onHostReady", - "onLayout", - "onMagicTap", - "onMoveShouldSetResponder", - "onMoveShouldSetResponderCapture", - "onResponderEnd", - "onResponderGrant", - "onResponderMove", - "onResponderReject", - "onResponderRelease", - "onResponderStart", - "onResponderTerminate", - "onResponderTerminationRequest", - "onStartShouldSetResponder", - "onStartShouldSetResponderCapture", - "pointerEvents", - "removeClippedSubviews", - "renderToHardwareTextureAndroid", - "shouldRasterizeIOS", - "style", - "testID", -]); - -function splitUIKitViewProps( - props: Props & UIKitHostViewProps, - definition: UIKitViewDefinition, -): { - nativeProps: ViewProps; - pluginProps: Props & UIKitHostViewProps; -} { - const nativeProps: Record = {}; - const pluginProps: Record = {}; - - 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; - } - } - - Object.assign(nativeProps, definition.nativeProps?.(props)); - - return { - nativeProps: nativeProps as ViewProps, - pluginProps: pluginProps as Props & UIKitHostViewProps, - }; -} - -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"); - } - - const pointer = interop.handleof(view); - if (!pointer) { - throw new Error( - "UIKit view definition returned a value without a native handle", - ); - } - - if (typeof pointer.toHexString === "function") { - const text = pointer.toHexString(); - if (typeof text === "string" && text.length > 0) { - return text; - } - } - - if (typeof pointer.address === "string" && pointer.address.length > 0) { - return pointer.address; - } - - if (typeof pointer.address === "number") { - return String(pointer.address); - } - - if (typeof pointer.toNumber === "function") { - return String(pointer.toNumber()); - } - - throw new Error("UIKit view native handle could not be read"); -} - -function nativeHandleOrUndefined(value: unknown): string | undefined { - "worklet"; - - return value == null ? undefined : nativeHandleForUIKitView(value); -} - -function nativeHandleForNSObject(value: unknown): string | undefined { - "worklet"; - - 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 undefined; -} - function ensureNativeScriptInstalled(): void { if (!isInstalled()) { init(); @@ -1028,7 +766,7 @@ function requireReactNativeWorklets(): NativeScriptWorklets { return require(workletsPackageName) as NativeScriptWorklets; } catch (error) { throw workletsSetupError( - `NativeScript.runOnUI requires ${workletsPackageName}`, + `NativeScript.scheduleOnUI requires ${workletsPackageName}`, ); } } @@ -1043,251 +781,12 @@ function validateWorkletsModule( typeof worklets.runOnUIAsync !== "function" ) { throw workletsSetupError( - "NativeScript.runOnUI received an incompatible Worklets module", + "NativeScript.scheduleOnUI 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 = globalObject.NSTimer; - const NSRunLoopClass = globalObject.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 = - typeof delay === "number" && Number.isFinite(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; @@ -1315,41 +814,49 @@ export function installWorklets( const holder = validWorklets.getUIRuntimeHolder(); if (holder == null || typeof holder !== "object") { throw workletsSetupError( - "NativeScript.runOnUI could not resolve a Worklets UI runtime", + "NativeScript.scheduleOnUI could not resolve a Worklets UI runtime", ); } - const installRuntime = NativeScriptNativeApi.installWorkletRuntime; + // Best-effort: an older/incompatible Worklets module without + // getUISchedulerHolder still installs fine; the gateway falls back to a + // plain dispatch_async(main) when no scheduler is available. + const schedulerHolder = + typeof validWorklets.getUISchedulerHolder === "function" + ? validWorklets.getUISchedulerHolder() + : {}; + const installRuntime = NativeScriptNativeApi.installUIRuntime; if (typeof installRuntime !== "function") { throw workletsSetupError( "NativeScript Native API was built without RNWorklets runtime support", ); } - const installed = installRuntime(holder, metadataPath); + const installed = installRuntime( + holder, + schedulerHolder as object, + metadataPath, + ); if (!installed) { throw workletsSetupError( "NativeScript Native API could not install into the Worklets UI runtime", ); } - validWorklets - .runOnUIAsync(installIdleAwareWorkletsFrameLoop) - .catch(() => undefined); workletsAdapter = validWorklets; return true; } -export function runOnUI( +export function scheduleOnUI( callback: (...args: Args) => ReturnValue | Promise, ...args: Args ): Promise { if (typeof callback !== "function") { - throw new TypeError("NativeScript.runOnUI expects a Worklets callback"); + throw new TypeError("NativeScript.scheduleOnUI expects a Worklets callback"); } ensureNativeScriptInstalled(); const worklets = ensureWorkletsInstalled(); if (worklets.isWorkletFunction(callback) !== true) { throw workletsSetupError( - "NativeScript.runOnUI requires a worklet callback", + "NativeScript.scheduleOnUI requires a worklet callback", ); } return worklets.runOnUIAsync(callback, ...args); @@ -1419,7 +926,7 @@ function callbackInvoker( 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().', + 'NativeScript.uiInvoker is not supported in React Native. Use a Worklets "worklet" callback with NativeScript.scheduleOnUI().', ); } @@ -1517,7 +1024,7 @@ export function isMainThread(): boolean { } export function assertUIKitThread( - message = "UIKit native APIs must be called through NativeScript.runOnUI", + message = "UIKit native APIs must be called through NativeScript.scheduleOnUI", ): void { "worklet"; @@ -1526,30 +1033,6 @@ export function assertUIKitThread( } } -export function refreshUIKitHostView(view: unknown): boolean { - "worklet"; - - const refresh = (globalThis as Record) - .__nativeScriptRefreshUIKitHostView; - if (typeof refresh !== "function") { - return false; - } - - return refresh(nativeHandleForUIKitView(view)) === true; -} - -export function refreshUIKitHostViewHandle(viewHandle: string): boolean { - "worklet"; - - const refresh = (globalThis as Record) - .__nativeScriptRefreshUIKitHostView; - if (typeof refresh !== "function") { - return false; - } - - return refresh(viewHandle) === true; -} - export function loadImage( source: unknown, options: NativeScriptImageLoadOptions = {}, @@ -1586,7 +1069,7 @@ export function loadImage( } export function warnIfNotUIKitThread( - message = "UIKit native APIs should be mutated through NativeScript.runOnUI", + message = "UIKit native APIs should be mutated through NativeScript.scheduleOnUI", ): boolean { "worklet"; @@ -1820,1425 +1303,14 @@ export function createDelegate( return delegate; } -type UIKitRuntimeContext = UIKitViewContext & { - createArgument(): UIKitCreateArgument; - disposeResources(): void; - isDisposed(): boolean; -}; - -type UIKitHostInstance = { - hostView: unknown; - lifecycleValue: NativeView; - childrenView?: unknown; - controller?: unknown; -}; - -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; -}; - -type PendingUIKitHost = { - debugName: string; - mountHost: () => RegisteredUIKitHost; - propsRef: { current: Readonly }; -}; - -type UIKitHostHandles = { - nativeViewHandle?: string; - childrenViewHandle?: string; - controllerHandle?: string; -}; - -type UIKitAdapterDefinition< - Props extends object, - NativeView, -> = UIKitViewDefinition & { - resolveHostInstance?: (created: NativeView) => UIKitHostInstance; -}; - -const uikitHostRegistryGlobalName = "__nativeScriptUIKitHostRegistry"; -const pendingUIKitHostRegistryGlobalName = - "__nativeScriptPendingUIKitHostRegistry"; -const createUIKitHostFromNativeGlobalName = - "__nativeScriptCreateUIKitHostFromNative"; -const runUIKitHostLifecycleFromNativeGlobalName = - "__nativeScriptRunUIKitHostLifecycleFromNative"; -let nextUIKitHostId = 1; - -function createUIKitHostId(debugName: string): string { - return `${debugName}:${nextUIKitHostId++}`; -} - -function uikitHostRegistry(): Map> { - "worklet"; - - const globalObject = globalThis as Record; - const existing = globalObject[uikitHostRegistryGlobalName]; - if (existing instanceof Map) { - return existing as Map>; - } - - const registry = new Map>(); - Object.defineProperty(globalThis, uikitHostRegistryGlobalName, { - configurable: true, - enumerable: false, - writable: false, - value: registry, - }); - return registry; -} - -function pendingUIKitHostRegistry(): Map< - string, - PendingUIKitHost -> { - "worklet"; - - const globalObject = globalThis as Record; - const existing = globalObject[pendingUIKitHostRegistryGlobalName]; - if (existing instanceof Map) { - return existing as Map>; - } - - const registry = new Map>(); - Object.defineProperty(globalThis, pendingUIKitHostRegistryGlobalName, { - configurable: true, - enumerable: false, - writable: false, - value: registry, - }); - return registry; -} - -function uikitHostHandles( - host: RegisteredUIKitHost, -): UIKitHostHandles { - "worklet"; - - return { - nativeViewHandle: nativeHandleOrUndefined(host.hostInstance.hostView), - childrenViewHandle: nativeHandleOrUndefined(host.hostInstance.childrenView), - controllerHandle: nativeHandleForNSObject(host.hostInstance.controller), - }; -} - -function getRegisteredUIKitHost( - hostId: string, -): RegisteredUIKitHost { - "worklet"; - - const host = uikitHostRegistry().get(hostId); - if (!host) { - throw new Error(`UIKit host ${hostId} has not been created`); - } - return host as RegisteredUIKitHost; -} - -function registerUIKitHost( - hostId: string, - host: RegisteredUIKitHost, -): void { - "worklet"; - - uikitHostRegistry().set(hostId, host as RegisteredUIKitHost); -} - -function createRegisteredUIKitHostFromNative( - hostId: string, -): UIKitHostHandles | null { - "worklet"; - - const existingHost = uikitHostRegistry().get(hostId); - if (existingHost) { - return uikitHostHandles(existingHost); - } - - const pending = pendingUIKitHostRegistry().get(hostId); - if (!pending) { - return null; - } - - const host = pending.mountHost(); - registerUIKitHost(hostId, host); - return uikitHostHandles(host); -} - -function ensureRegisteredUIKitHost( - hostId: string, -): RegisteredUIKitHost | null { - "worklet"; - - const existingHost = uikitHostRegistry().get(hostId); - if (existingHost) { - return existingHost as RegisteredUIKitHost; - } - - if (createRegisteredUIKitHostFromNative(hostId) == null) { - return null; - } - - const createdHost = uikitHostRegistry().get(hostId); - return (createdHost ?? null) as RegisteredUIKitHost | null; -} - -function disposeRegisteredUIKitHost( - hostId: string, - props: Readonly, -): void { - "worklet"; - - pendingUIKitHostRegistry().delete(hostId); - const registry = uikitHostRegistry(); - const host = registry.get(hostId) as - | RegisteredUIKitHost - | undefined; - if (!host) { - return; - } - registry.delete(hostId); - host.propsRef.current = props; - const disposeResult = host.dispose?.(props); - host.context.disposeResources(); - const maybeView = host.hostInstance.hostView as - | Record - | undefined; - if ( - disposeResult?.removeHostView !== false && - typeof maybeView?.removeFromSuperview === "function" - ) { - maybeView.removeFromSuperview(); - } -} - -function syncUIKitHostPropsFromReact( - hostId: string, - props: Readonly, -): void { - "worklet"; - - const pending = pendingUIKitHostRegistry().get(hostId); - if (pending) { - pending.propsRef.current = props; - } - - const host = uikitHostRegistry().get(hostId); - if (host) { - host.propsRef.current = props; - } -} - -function runUIKitHostLifecycleFromNative( - hostId: string, - phase: string, -): UIKitHostHandles | null { - "worklet"; - - if (phase === "dispose") { - const host = uikitHostRegistry().get(hostId); - const pending = pendingUIKitHostRegistry().get(hostId); - disposeRegisteredUIKitHost( - hostId, - host?.propsRef.current ?? pending?.propsRef.current ?? {}, - ); - return null; - } - - const handles = createRegisteredUIKitHostFromNative(hostId); - if (handles == null) { - return null; - } - - const host = getRegisteredUIKitHost(hostId); - const nextProps = host.propsRef.current; - if (phase === "update") { - if (host.previousProps !== nextProps) { - host.update?.(nextProps, host.previousProps); - host.previousProps = nextProps; - } - } else if (phase === "mounted" && !host.hasMounted) { - host.hasMounted = true; - host.mounted?.(nextProps); - } - - return uikitHostHandles(host); -} - -function installUIKitNativeMountBridge(): void { - "worklet"; - - const globalObject = globalThis as Record; - if (typeof globalObject[createUIKitHostFromNativeGlobalName] !== "function") { - Object.defineProperty(globalThis, createUIKitHostFromNativeGlobalName, { - configurable: true, - enumerable: false, - writable: false, - value: createRegisteredUIKitHostFromNative, - }); - } - if ( - typeof globalObject[runUIKitHostLifecycleFromNativeGlobalName] !== - "function" - ) { - Object.defineProperty( - globalThis, - runUIKitHostLifecycleFromNativeGlobalName, - { - configurable: true, - enumerable: false, - writable: false, - value: runUIKitHostLifecycleFromNative, - }, - ); - } -} - -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 { - "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; -} - -function targetActionCallbacksForRuntime(): Map< - string, - (sender: unknown) => void -> { - "worklet"; - - return runtimeGlobalMap<(sender: unknown) => void>( - targetActionCallbacksGlobalName, - ); -} - -function observerCallbacksForRuntime(): Map< - string, - (keyPath: string, object: unknown, change: unknown) => void -> { - "worklet"; - - return runtimeGlobalMap< - (keyPath: string, object: unknown, change: unknown) => void - >(observerCallbacksGlobalName); -} - -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"; - - 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 getObserverClass(): any { - "worklet"; - - const globalObject = globalThis as Record; - const cached = globalObject[observerClassGlobalName]; - if (cached) { - return cached; - } - const types = objcInteropTypes(); - const NSObject = requireNSObject(); - const NSString = (globalThis as Record).NSString; - const NSDictionary = (globalThis as Record).NSDictionary; - const Pointer = - (globalThis as Record).interop?.Pointer ?? types?.id; - - const observerClass = NSObject.extend( - { - "observeValueForKeyPath:ofObject:change:context:"( - keyPath: string, - object: unknown, - change: unknown, - ) { - const callback = observerCallbacksForRuntime().get( - nativeCallbackKey(this), - ); - if (typeof callback === "function") { - callback(keyPath, object, change); - } - }, - }, - { - exposedMethods: { - "observeValueForKeyPath:ofObject:change:context:": { - returns: types?.void, - params: [ - NSString ?? NSObject, - NSObject, - NSDictionary ?? NSObject, - Pointer, - ], - }, - }, - }, - ); - Object.defineProperty(globalThis, observerClassGlobalName, { - configurable: true, - enumerable: false, - writable: false, - value: observerClass, - }); - return observerClass; -} - -function createUIKitContext( - name: string, - propsRef: { current: Props }, - invalidateLayout: () => void, -): UIKitRuntimeContext { - "worklet"; - - const retained: unknown[] = []; - const cleanupCallbacks: Array<() => void> = []; - let disposed = false; - - const context: UIKitRuntimeContext = { - get name() { - return name; - }, - get tag() { - return null; - }, - get props() { - return propsRef.current; - }, - emit(eventName, payload) { - if (disposed) { - return; - } - const handler = (propsRef.current as Record)[ - eventName as PropertyKey - ]; - if (typeof handler !== "function") { - return; - } - 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([payload])); - } else { - setTimeout(() => { - if (!disposed) { - (handler as Function)(payload); - } - }, 0); - } - }, - targetAction(control, events, callback) { - if (control == null || typeof callback !== "function") { - return; - } - const target = getTargetActionClass().alloc().init(); - const targetKey = nativeCallbackKey(target); - targetActionCallbacksForRuntime().set(targetKey, () => { - if (!disposed) { - invokeNativeScriptCallback(callback, [], () => disposed); - } - }); - const selector = "nativeScriptHandleAction:"; - const nativeControl = control as Record; - if (typeof nativeControl.addTargetActionForControlEvents !== "function") { - throw new Error("targetAction expects a UIControl-compatible object"); - } - nativeControl.addTargetActionForControlEvents(target, selector, events); - context.retain(target); - context.dispose(() => { - if ( - typeof nativeControl.removeTargetActionForControlEvents === "function" - ) { - nativeControl.removeTargetActionForControlEvents( - target, - selector, - events, - ); - } - targetActionCallbacksForRuntime().delete(targetKey); - }); - }, - gestureAction(gesture, callback) { - if (gesture == null || typeof callback !== "function") { - return; - } - const target = getTargetActionClass().alloc().init(); - const targetKey = nativeCallbackKey(target); - targetActionCallbacksForRuntime().set(targetKey, (sender) => { - if (!disposed) { - callback(sender ?? gesture); - } - }); - const selector = "nativeScriptHandleAction:"; - const nativeGesture = gesture as Record; - if (typeof nativeGesture.addTargetAction !== "function") { - throw new Error( - "gestureAction expects a UIGestureRecognizer-compatible object", - ); - } - nativeGesture.addTargetAction(target, selector); - context.retain(target); - context.dispose(() => { - if (typeof nativeGesture.removeTargetAction === "function") { - nativeGesture.removeTargetAction(target, selector); - } - targetActionCallbacksForRuntime().delete(targetKey); - }); - }, - actionTarget(callback) { - if (typeof callback !== "function") { - throw new Error("actionTarget expects a callback"); - } - - const target = getTargetActionClass().alloc().init(); - const targetKey = nativeCallbackKey(target); - targetActionCallbacksForRuntime().set(targetKey, (sender) => { - if (!disposed) { - invokeNativeScriptCallback(callback, [sender], () => disposed); - } - }); - context.retain(target); - context.dispose(() => { - targetActionCallbacksForRuntime().delete(targetKey); - }); - - return { - target, - action: "nativeScriptHandleAction:", - }; - }, - delegate(object, protocolRef, implementation) { - const protocolList = [protocolRef as NativeProtocolReference] - .map(resolveProtocolReference) - .filter(Boolean); - if (protocolList.length === 0) { - throw new Error("NativeScript UIKit delegate requires a protocol"); - } - - const nativeObject = object as Record; - const assignedObject = - nativeObject && "delegate" in nativeObject ? nativeObject : undefined; - const DelegateClass = requireNSObject().extend( - wrapDelegateMethods(implementation, "caller"), - { - protocols: protocolList, - }, - ); - const delegate = DelegateClass.alloc().init() as T; - context.retain(delegate); - if (assignedObject) { - assignedObject.delegate = delegate; - } - context.dispose(() => { - if (assignedObject && assignedObject.delegate === delegate) { - assignedObject.delegate = null; - } - context.release(delegate); - }); - return delegate; - }, - notification(name, object, callback) { - const center = (globalThis as Record).NSNotificationCenter - ?.defaultCenter; - if (!center) { - throw new Error("NSNotificationCenter.defaultCenter is not available"); - } - const observer = center.addObserverForNameObjectQueueUsingBlock( - name, - object ?? null, - null, - (notification: unknown) => { - if (!disposed) { - callback(notification); - } - }, - ); - context.retain(observer); - context.dispose(() => { - center.removeObserver(observer); - }); - }, - observe(object, keyPath, callback) { - const nativeObject = object as Record; - if ( - object == null || - typeof nativeObject.addObserverForKeyPathOptionsContext !== "function" - ) { - throw new Error("observe expects a KVO-compatible NSObject"); - } - const observer = getObserverClass().alloc().init(); - const observerKey = nativeCallbackKey(observer); - observerCallbacksForRuntime().set( - observerKey, - ( - observedKeyPath: string, - _observedObject: unknown, - change: unknown, - ) => { - if (disposed || String(observedKeyPath) !== keyPath) { - return; - } - const newKey = (globalThis as Record) - .NSKeyValueChangeNewKey; - const value = - change && - typeof (change as Record).objectForKey === - "function" - ? (change as Record).objectForKey(newKey) - : undefined; - callback(value, change); - }, - ); - const options = (globalThis as Record) - .NSKeyValueObservingOptions; - const optionNew = - typeof options?.New === "number" - ? options.New - : ((globalThis as Record).NSKeyValueObservingOptionNew ?? - 1); - nativeObject.addObserverForKeyPathOptionsContext( - observer, - keyPath, - optionNew, - null, - ); - context.retain(observer); - context.dispose(() => { - try { - if (typeof nativeObject.removeObserverForKeyPath === "function") { - nativeObject.removeObserverForKeyPath(observer, keyPath); - } - } finally { - observerCallbacksForRuntime().delete(observerKey); - } - }); - }, - retain(value) { - retained.push(value); - return value; - }, - 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); - }, - disposeResources() { - if (disposed) { - return; - } - disposed = true; - for (let i = cleanupCallbacks.length - 1; i >= 0; i--) { - cleanupCallbacks[i](); - } - cleanupCallbacks.length = 0; - retained.length = 0; - }, - isDisposed() { - return disposed; - }, - }; - - return context; -} - -function constrainedSize( - size: { width: number; height: number }, - layout?: UIKitLayoutOptions, -): { width: number; height: number } { - "worklet"; - - const defaultSize = layout?.defaultSize ?? {}; - let width = - Number.isFinite(size.width) && size.width >= 0 - ? size.width - : (defaultSize.width ?? 0); - let height = - Number.isFinite(size.height) && size.height >= 0 - ? size.height - : (defaultSize.height ?? 0); - - if (layout?.minSize?.width != null) { - width = Math.max(width, layout.minSize.width); - } - if (layout?.minSize?.height != null) { - height = Math.max(height, layout.minSize.height); - } - if (layout?.maxSize?.width != null) { - width = Math.min(width, layout.maxSize.width); - } - if (layout?.maxSize?.height != null) { - height = Math.min(height, layout.maxSize.height); - } - return { width, height }; -} - -function flattenedStyleSize(style: ViewProps["style"]) { - "worklet"; - - const flat: Record = {}; - const applyStyle = (value: unknown) => { - if (Array.isArray(value)) { - for (const item of value) { - applyStyle(item); - } - return; - } - if (!value || typeof value !== "object") { - return; - } - const record = value as Record; - if (typeof record.width === "number") { - flat.width = record.width; - } - if (typeof record.height === "number") { - flat.height = record.height; - } - }; - applyStyle(style); - return { - width: typeof flat.width === "number" ? flat.width : undefined, - height: typeof flat.height === "number" ? flat.height : undefined, - }; -} - -function makeCGSize(width: number, height: number) { - "worklet"; - - const CGSizeMake = (globalThis as Record).CGSizeMake; - if (typeof CGSizeMake === "function") { - return CGSizeMake(width, height); - } - return { width, height }; -} - -function readNativeSize(size: unknown): { width: number; height: number } { - "worklet"; - - const nativeSize = size as { width?: unknown; height?: unknown }; - return { - width: Number(nativeSize?.width ?? 0), - height: Number(nativeSize?.height ?? 0), - }; -} - -function measureUIKitView( - view: unknown, - layout: UIKitLayoutOptions | undefined, - style: ViewProps["style"], -): { width: number; height: number } { - "worklet"; - - const mode = layout?.sizing ?? "fill"; - if (mode === "fill") { - return constrainedSize( - layout?.defaultSize ?? { width: 0, height: 0 }, - layout, - ); - } - - const styleSize = flattenedStyleSize(style); - const nativeView = view as Record; - let measured = layout?.defaultSize ?? { width: 0, height: 0 }; - - if (mode === "intrinsic") { - measured = readNativeSize(nativeView.intrinsicContentSize); - } else if ( - mode === "sizeThatFits" && - typeof nativeView.sizeThatFits === "function" - ) { - measured = readNativeSize( - nativeView.sizeThatFits( - makeCGSize( - styleSize.width ?? Number.MAX_SAFE_INTEGER, - styleSize.height ?? Number.MAX_SAFE_INTEGER, - ), - ), - ); - } else if ( - mode === "autoLayout" && - typeof nativeView.systemLayoutSizeFittingSize === "function" - ) { - const fittingSize = - (globalThis as Record).UIView?.layoutFittingCompressedSize ?? - makeCGSize(styleSize.width ?? 0, styleSize.height ?? 0); - measured = readNativeSize( - nativeView.systemLayoutSizeFittingSize(fittingSize), - ); - } - - return constrainedSize( - { - width: styleSize.width ?? measured.width, - height: styleSize.height ?? measured.height, - }, - layout, - ); -} - -function defineUIKitHost( - definition: UIKitAdapterDefinition, -): UIKitViewComponent { - const debugName = - definition.debugName || - definition.name || - definition.displayName || - "NativeScriptUIKitView"; - - 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; - const layoutSizing = layout?.sizing ?? "fill"; - const hostIdRef = useRef(null); - if (hostIdRef.current == null) { - hostIdRef.current = createUIKitHostId(debugName); - } - const hostId = hostIdRef.current; - const propsRef = useRef(pluginProps); - const previousPropsRef = useRef | undefined>(); - const mountedRef = useRef(false); - const disposedRef = useRef(false); - const updateMeasuredSizeRef = useRef<() => void>(() => {}); - const [nativeHostRevision, setNativeHostRevision] = useState(0); - const attachController = props.attachController !== false; - const attachControllerView = props.attachControllerView !== false; - const attachNativeView = props.attachNativeView !== false; - const mountThroughNativeHost = attachController; - - const invalidateLayout = () => { - updateMeasuredSizeRef.current(); - }; - - const [nativeViewHandle, setNativeViewHandle] = useState< - string | undefined - >(); - const [childrenViewHandle, setChildrenViewHandle] = useState< - string | undefined - >(); - const [controllerHandle, setControllerHandle] = useState< - string | undefined - >(); - const [measuredSize, setMeasuredSize] = useState< - { width: number; height: number } | undefined - >(() => - layoutSizing === "fill" - ? undefined - : layout?.defaultSize - ? { - width: layout.defaultSize.width ?? 0, - height: layout.defaultSize.height ?? 0, - } - : undefined, - ); - const [error, setError] = useState(null); - - propsRef.current = pluginProps; - - const applyHostHandles = (handles: UIKitHostHandles | null | undefined) => { - if (handles == null) { - return; - } - - setNativeViewHandle((previous) => - previous === handles.nativeViewHandle - ? previous - : handles.nativeViewHandle, - ); - setChildrenViewHandle((previous) => - previous === handles.childrenViewHandle - ? previous - : handles.childrenViewHandle, - ); - setControllerHandle((previous) => - previous === handles.controllerHandle - ? previous - : handles.controllerHandle, - ); - }; - - const updateMeasuredSize = () => { - if (nativeViewHandle == null || layoutSizing === "fill") { - return; - } - runOnUI(() => { - const host = getRegisteredUIKitHost(hostId); - return measureUIKitView( - host.hostInstance.hostView, - layout, - nativeProps.style, - ); - }) - .then((nextSize) => { - setMeasuredSize((previous) => - previous && - previous.width === nextSize.width && - previous.height === nextSize.height - ? previous - : nextSize, - ); - }) - .catch((reason) => { - setError( - reason instanceof Error ? reason : new Error(String(reason)), - ); - }); - }; - updateMeasuredSizeRef.current = updateMeasuredSize; - - useImperativeHandle( - ref, - () => ({ - get nativeView() { - return null; - }, - runOnUI(callback) { - return runOnUI(() => { - const host = getRegisteredUIKitHost(hostId); - return callback(host.nativeView); - }); - }, - measureNative() { - return runOnUI(() => { - const host = getRegisteredUIKitHost(hostId); - return measureUIKitView( - host.hostInstance.hostView, - layout, - nativeProps.style, - ); - }); - }, - invalidateNativeLayout() { - updateMeasuredSize(); - }, - }), - [hostId, layout, nativeProps.style], - ); - - useLayoutEffect(() => { - disposedRef.current = false; - let cancelled = false; - - 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) - .then((handles) => { - if (cancelled || disposedRef.current) { - return; - } - previousPropsRef.current = propsRef.current; - applyHostHandles(handles); - setNativeHostRevision((revision) => revision + 1); - updateMeasuredSize(); - }) - .catch((reason) => { - setError( - reason instanceof Error ? reason : new Error(String(reason)), - ); - }); - - return () => { - cancelled = true; - disposedRef.current = true; - mountedRef.current = false; - runOnUI(() => { - if (!uikitHostRegistry().has(hostId)) { - pendingUIKitHostRegistry().delete(hostId); - } - }).catch((reason) => { - setError( - reason instanceof Error ? reason : new Error(String(reason)), - ); - }); - }; - } - - 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) - .then((handles) => { - if (handles == null) { - throw new Error(`UIKit host ${hostId} was not created`); - } - if (cancelled || disposedRef.current) { - const disposeProps = propsRef.current; - runOnUI((currentProps) => { - disposeRegisteredUIKitHost(hostId, currentProps); - }, disposeProps).catch((reason) => { - setError( - reason instanceof Error ? reason : new Error(String(reason)), - ); - }); - return; - } - previousPropsRef.current = propsRef.current; - applyHostHandles(handles); - updateMeasuredSize(); - }) - .catch((reason) => { - setError( - reason instanceof Error ? reason : new Error(String(reason)), - ); - }); - - return () => { - cancelled = true; - disposedRef.current = true; - mountedRef.current = false; - const disposeProps = propsRef.current; - runOnUI((currentProps) => { - disposeRegisteredUIKitHost(hostId, currentProps); - }, disposeProps).catch((reason) => { - setError( - reason instanceof Error ? reason : new Error(String(reason)), - ); - }); - }; - }, [ - createHost, - debugName, - disposeHost, - hostId, - mountedHost, - mountThroughNativeHost, - resolveHostInstance, - updateHost, - ]); - - useEffect(() => { - if (nativeViewHandle == null && !mountThroughNativeHost) { - return; - } - - const currentProps = propsRef.current; - const previousProps = previousPropsRef.current; - previousPropsRef.current = currentProps; - - if (mountThroughNativeHost) { - runOnUI( - (nextProps, fallbackPreviousProps) => { - syncUIKitHostPropsFromReact(hostId, nextProps); - const host = ensureRegisteredUIKitHost(hostId); - if (!host) { - return null; - } - host.propsRef.current = nextProps; - updateHost?.( - host.nativeView, - nextProps, - host.previousProps ?? fallbackPreviousProps, - host.context, - ); - host.previousProps = nextProps; - return uikitHostHandles(host); - }, - currentProps, - previousProps, - ) - .then(applyHostHandles) - .catch((reason) => { - setError( - reason instanceof Error ? reason : new Error(String(reason)), - ); - }); - updateMeasuredSize(); - return; - } - - runOnUI( - (nextProps, fallbackPreviousProps) => { - const host = ensureRegisteredUIKitHost(hostId); - if (!host) { - return; - } - host.propsRef.current = nextProps; - updateHost?.( - host.nativeView, - nextProps, - host.previousProps ?? fallbackPreviousProps, - host.context, - ); - host.previousProps = nextProps; - }, - currentProps, - previousProps, - ).catch((reason) => { - setError(reason instanceof Error ? reason : new Error(String(reason))); - }); - updateMeasuredSize(); - }, [ - hostId, - mountThroughNativeHost, - nativeViewHandle, - pluginProps, - updateHost, - ]); - - useEffect(() => { - if ( - mountedRef.current || - (nativeViewHandle == null && !mountThroughNativeHost) - ) { - return; - } - - if (mountThroughNativeHost) { - mountedRef.current = true; - return; - } - - mountedRef.current = true; - const currentProps = propsRef.current; - const isDisposed = disposedRef.current; - runOnUI( - (nextProps, shouldSkipMounted) => { - if (!shouldSkipMounted) { - const host = ensureRegisteredUIKitHost(hostId); - if (!host) { - return; - } - host.propsRef.current = nextProps; - mountedHost?.(host.nativeView, nextProps, host.context); - } - }, - currentProps, - isDisposed, - ).catch((reason) => { - setError(reason instanceof Error ? reason : new Error(String(reason))); - }); - }, [hostId, mountedHost, mountThroughNativeHost, nativeViewHandle]); - - if (error) { - throw error; - } - - const layoutStyle = - measuredSize && layoutSizing !== "fill" - ? { - width: measuredSize.width, - height: measuredSize.height, - } - : undefined; - 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 - : undefined, - nativeViewHandle: attachNativeView ? nativeViewHandle : undefined, - style: layoutStyle ? [nativeProps.style, layoutStyle] : nativeProps.style, - updateRevision: - mountThroughNativeHost && nativeHostRevision > 0 - ? nativeHostRevision - : undefined, - }); - }); - - Component.displayName = - definition.displayName || definition.name || debugName; - return Component; -} - -export function defineUIKitView( - definition: UIKitViewDefinition, -): UIKitViewComponent { - return defineUIKitHost(definition); -} - -export function defineUIKitContainer< - Props extends object, - RootView = unknown, - ChildrenView = unknown, ->( - definition: UIKitContainerDefinition, -): UIKitViewComponent> { - return defineUIKitHost({ - ...definition, - resolveHostInstance(created) { - "worklet"; - - return { - hostView: created.rootView, - lifecycleValue: created, - childrenView: created.childrenView, - }; - }, - } as UIKitAdapterDefinition< - Props, - UIKitContainerResult - >); -} - -export function defineUIViewController< - Props extends object, - Controller = unknown, ->( - definition: UIViewControllerDefinition, -): UIKitViewComponent { - return defineUIKitHost({ - ...definition, - create: definition.createController, - resolveHostInstance(controller) { - "worklet"; - - const controllerRecord = controller as Record; - return { - hostView: definition.hostView?.(controller) ?? controllerRecord.view, - lifecycleValue: controller, - childrenView: definition.childrenView?.(controller), - controller, - }; - }, - } as UIKitAdapterDefinition); -} - const NativeScript = { init, install, installGlobals, isInstalled, defaultMetadataPath, - defineUIKitContainer, - defineUIKitView, - defineUIViewController, + defineNativeComponent, + dispatchNativeComponentCommand, getRuntimeBackend, installWorklets, assertUIKitThread, @@ -3255,8 +1327,7 @@ const NativeScript = { loadFramework, release, retain, - refreshUIKitHostView, - runOnUI, + scheduleOnUI, runtimeInvoker, uiInvoker, warnIfNotUIKitThread, diff --git a/packages/react-native/src/ui/dispatcher.ts b/packages/react-native/src/ui/dispatcher.ts new file mode 100644 index 000000000..2c59920c6 --- /dev/null +++ b/packages/react-native/src/ui/dispatcher.ts @@ -0,0 +1,283 @@ +/** + * The worklet-side half of the Fabric boundary (ARCHITECTURE.md §5, §7.1). + * Runs ENTIRELY on the UI runtime, main thread. Owns: + * - the tag-keyed instance table (`ctx.instance`'s home) + * - `ctx` construction, once per instance, at `create` + * - lifecycle dispatch: the single `__nativeScriptDispatchComponentHook` + * global that NativeScriptComponentView.mm calls into for every Fabric + * hook it forwards + * + * Native's job stops at "hand this file a materialized spec object once per + * name per UI-runtime generation, then call the one dispatcher function" -- + * everything after that (which hook fires, what `ctx` looks like, the + * tag -> instance table) is ordinary worklet JS, per the file split + * ARCHITECTURE.md §7.1 calls for. + */ +// M1 review §3/#3 (fix-list item 8): renamed from `runOnUI`; exported +// under the ecosystem's dominant CURRIED `runOnUI(fn)(args)` name (Reanimated) +// while being a flat `(fn, ...args) => Promise` shape caused a real shipped +// crash (the implementer's own bug #1). `scheduleOnUI` matches worklets' +// own `runOnUIAsync` naming convention instead of colliding with it. +import { scheduleOnUI, createDelegate as createDelegateImpl } from "../index"; + +// Mirrors NativeScriptFabricGateway.h's NativeScriptComponentHook enum -- +// keep in sync; native and TS never negotiate these values at runtime. +export const NativeScriptComponentHook = { + UpdateProps: 1 << 0, + MountChild: 1 << 1, + UnmountChild: 1 << 2, + WillMount: 1 << 3, + DidMount: 1 << 4, + UpdateLayoutMetrics: 1 << 5, + FinalizeUpdates: 1 << 6, + PrepareForRecycle: 1 << 7, + Commands: 1 << 8, +} as const; + +// The `NativeView` a worklet gets everywhere; the ComponentView itself, +// NS-wrapped, with full UIKit access (walk `nextResponder`, add +// constraints, VC containment; ARCHITECTURE.md §5.1's ctx.view row). Kept +// `unknown`-typed at this layer; `defineNativeComponent.ts` narrows it per +// spec via the author's own `NativeView` generic. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type NativeView = any; + +// M1 review §1/#2: native now forwards every Insert/Remove/Delete mutation +// in the transaction (see NativeScriptComponentView.mm's +// NativeScriptBuildMutationsArray) instead of a zero-argument notification -- +// this is what makes `willBeUnmountedInUpcomingTransaction`-style dismissal +// detection (a Delete mutation for a tag) and "which child changed" both +// expressible, matching RNSScreenStack.mm:1338-1366's own scan. +export type TransactionMutation = { + type: "insert" | "remove" | "delete"; + tag: number; + parentTag: number; + index: number; +}; + +export type MountingTransaction = { + readonly mutations: TransactionMutation[]; + /** True if an insert/remove mutation targets `tag` as its parent; the + * RNSScreenStack.mm:1349-1366 `didMount -> maybeAddToParentAndUpdateContainer` + * predicate, exactly as ARCHITECTURE.md §6's worked example calls it. */ + didMutateChildrenOf(tag: number): boolean; +}; + +function buildMountingTransaction(mutations: TransactionMutation[]): MountingTransaction { + "worklet"; + return { + mutations, + didMutateChildrenOf(tag: number): boolean { + "worklet"; + return mutations.some( + (m) => m.parentTag === tag && (m.type === "insert" || m.type === "remove"), + ); + }, + }; +} + +export type NSComponentContext> = { + readonly view: NativeView; + readonly instance: Instance; + readonly tag: number; + emit(name: string, payload?: unknown): void; + setContentSize( + size: { width: number; height: number }, + opts?: { offsetY?: number; authority?: boolean }, + ): void; + scheduleOnMainQueue(fn: () => void): void; + createDelegate: typeof createDelegateImpl; + instanceForView(view: NativeView): unknown; +}; + +type ComponentSpec = { + create?: (ctx: NSComponentContext) => NativeView | void; + updateProps?: (ctx: NSComponentContext, next: unknown, prev: unknown) => void; + mountChildComponentView?: ( + ctx: NSComponentContext, + child: { tag: number; view: NativeView; instance?: unknown }, + index: number, + ) => void; + unmountChildComponentView?: ( + ctx: NSComponentContext, + child: { tag: number; view: NativeView; instance?: unknown }, + index: number, + ) => void; + mountingTransactionWillMount?: (ctx: NSComponentContext, txn: MountingTransaction) => void; + mountingTransactionDidMount?: (ctx: NSComponentContext, txn: MountingTransaction) => void; + updateLayoutMetrics?: ( + ctx: NSComponentContext, + next: { x: number; y: number; width: number; height: number }, + prev: { x: number; y: number; width: number; height: number }, + ) => boolean; + finalizeUpdates?: (ctx: NSComponentContext, mask: number) => void; + prepareForRecycle?: (ctx: NSComponentContext, viaInvalidate: boolean) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + commands?: Record void>; +}; + +let dispatcherInstallStarted = false; + +/** + * Idempotently pushes the dispatcher onto the UI runtime. Fire-and-forget + * (worklets' `runOnUI` is inherently async, microtask-batched per + * `threads.native.ts:340-397`); called from `defineNativeComponent.ts` at + * module-import time, well before any component this module defines could + * possibly be rendered (React must import the module before it can + * reference the component `defineNativeComponent` returns). + */ +export function ensureDispatcherInstalled(): void { + if (dispatcherInstallStarted) { + return; + } + dispatcherInstallStarted = true; + + scheduleOnUI(() => { + "worklet"; + + const globalObject = globalThis as Record; + if (typeof globalObject.__nativeScriptDispatchComponentHook === "function") { + return; // Already installed on this UI runtime instance (dev-reload safety). + } + + // tag -> {ctx, instance}. Lives on the UI runtime, dies with it (a + // Worklets reload creates a fresh Hermes VM, so this table; like + // every other UI-runtime global; is naturally scoped correctly with + // zero manual generation bookkeeping needed here; only native's + // materialized-spec cache needs the explicit generation counter, + // because IT persists as C++ state across VM instances). + const instances = new Map(); + // name -> materialized spec (hook functions), handed over once per name + // by native (registerMaterializedSpec) the first time any hook for that + // name fires. + const specs = new Map(); + + globalObject.__nativeScriptRegisterMaterializedSpec = (name: string, spec: ComponentSpec) => { + specs.set(name, spec); + }; + + function buildCtx(tag: number, view: NativeView): NSComponentContext { + const instance: Record = {}; + const ctx: NSComponentContext = { + view, + instance, + tag, + emit(name_, payload) { + "worklet"; + globalObject.__nativeScriptComponentEmit(view, name_, payload ?? null); + }, + setContentSize(size, opts) { + "worklet"; + globalObject.__nativeScriptComponentSetContentSize( + view, + size.width, + size.height, + opts?.offsetY ?? 0, + opts?.authority ?? true, + ); + }, + scheduleOnMainQueue(fn) { + "worklet"; + globalObject.__nativeScriptComponentScheduleOnMainQueue(fn); + }, + createDelegate: createDelegateImpl, + instanceForView(childView) { + "worklet"; + // UIView.tag is stock Apple/Fabric API (RCTComponentViewRegistry + // already sets it to the React tag before any of our lifecycle + // methods run); reading it through the ordinary interop bridge + // needs no bespoke native plumbing, and (hard-learned, see + // memory) sidesteps the fact that JS expandos on NS view proxies + // never round-trip: there is nothing stashed on the view itself + // here, just a real UIKit property read. + const childTag = (childView as { tag?: number } | null)?.tag; + return typeof childTag === "number" ? instances.get(childTag)?.instance : undefined; + }, + }; + return ctx; + } + + globalObject.__nativeScriptDispatchComponentHook = ( + name: string, + tag: number, + hookName: string, + view: NativeView, + a: unknown, + b: unknown, + c: unknown, + ): unknown => { + "worklet"; + const spec = specs.get(name); + + if (hookName === "create") { + const ctx = buildCtx(tag, view); + instances.set(tag, { ctx, instance: ctx.instance }); + return spec?.create ? spec.create(ctx) : undefined; + } + + let entry = instances.get(tag); + if (!entry) { + // Defensive only: NativeScriptComponentView.mm's -nsEnsureCreated + // guarantees `create` fires before any other hook is forwarded, so + // this should not happen in practice. + const ctx = buildCtx(tag, view); + entry = { ctx, instance: ctx.instance }; + instances.set(tag, entry); + } + const { ctx } = entry; + + switch (hookName) { + case "updateProps": + return spec?.updateProps ? spec.updateProps(ctx, a, b) : undefined; + case "mountChildComponentView": { + const childTag = b as number; + const childInstance = instances.get(childTag)?.instance; + return spec?.mountChildComponentView + ? spec.mountChildComponentView(ctx, { tag: childTag, view: a, instance: childInstance }, c as number) + : undefined; + } + case "unmountChildComponentView": { + const childTag = b as number; + const childInstance = instances.get(childTag)?.instance; + return spec?.unmountChildComponentView + ? spec.unmountChildComponentView(ctx, { tag: childTag, view: a, instance: childInstance }, c as number) + : undefined; + } + case "mountingTransactionWillMount": + return spec?.mountingTransactionWillMount + ? spec.mountingTransactionWillMount(ctx, buildMountingTransaction(a as TransactionMutation[])) + : undefined; + case "mountingTransactionDidMount": + return spec?.mountingTransactionDidMount + ? spec.mountingTransactionDidMount(ctx, buildMountingTransaction(a as TransactionMutation[])) + : undefined; + case "updateLayoutMetrics": + return spec?.updateLayoutMetrics + ? spec.updateLayoutMetrics( + ctx, + a as { x: number; y: number; width: number; height: number }, + b as { x: number; y: number; width: number; height: number }, + ) + : true; + case "finalizeUpdates": + return spec?.finalizeUpdates ? spec.finalizeUpdates(ctx, a as number) : undefined; + case "prepareForRecycle": { + const result = spec?.prepareForRecycle ? spec.prepareForRecycle(ctx, a as boolean) : undefined; + instances.delete(tag); // Always drop the entry after teardown. + return result; + } + case "handleCommand": { + const commandFn = spec?.commands?.[a as string]; + return commandFn ? commandFn(ctx, b) : undefined; + } + default: + return undefined; + } + }; + // `scheduleOnUI(callback, ...args)` is a FLAT signature here (unlike + // Reanimated's curried `runOnUI(fn)(args)`); it schedules and + // directly returns a `Promise`, so there is no trailing + // `()` to call. Fire-and-forget: nothing awaits install completion (see + // this function's own doc comment on why that's safe). + }).catch(() => undefined); +} diff --git a/packages/react-native/test/babel-plugin.test.js b/packages/react-native/test/babel-plugin.test.js deleted file mode 100644 index e69bf4e35..000000000 --- a/packages/react-native/test/babel-plugin.test.js +++ /dev/null @@ -1,54 +0,0 @@ -const assert = require('assert'); -const babel = require('@babel/core'); -const plugin = require('../plugin/babel-plugin'); - -function transform(source) { - return babel.transformSync(source, { - ast: false, - babelrc: false, - configFile: false, - plugins: [plugin], - }).code; -} - -const source = ` -import NativeScript, { - defineUIKitContainer, - defineUIKitView, - defineUIViewController, -} from '@nativescript/react-native'; - -defineUIKitView({ - create() { - return UIView.new(); - }, - update: (view) => view.setNeedsLayout(), -}); - -NativeScript.defineUIViewController({ - createController() { - return UIViewController.new(); - }, - childrenView: (controller) => controller.view, - mounted(controller) { - controller.view.setNeedsLayout(); - }, - dispose() {}, -}); - -defineUIKitContainer({ - create() { - return {rootView: UIView.new(), childrenView: UIView.new()}; - }, -}); -`; - -const output = transform(source); -const workletDirectiveCount = (output.match(/"worklet";/g) || []).length; -assert.strictEqual(workletDirectiveCount, 7); -assert(output.includes('create() {\n "worklet";')); -assert(output.includes('update: view => {\n "worklet";')); -assert(output.includes('createController() {\n "worklet";')); -assert(output.includes('childrenView: controller => {\n "worklet";')); - -console.log('babel plugin tests passed'); diff --git a/packages/react-native/test/uikit-controller-appearance-api.test.js b/packages/react-native/test/uikit-controller-appearance-api.test.js deleted file mode 100644 index e516862ff..000000000 --- a/packages/react-native/test/uikit-controller-appearance-api.test.js +++ /dev/null @@ -1,41 +0,0 @@ -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 hostView = read("ios/NativeScriptUIView.mm"); - -assert( - hostView.includes("NativeScriptShouldForwardControllerAppearance"), - "NativeScriptUIView should centralize visible-controller appearance fallback checks", -); -assert( - hostView.includes("NativeScriptHostedViewContainsControllerView"), - "NativeScriptUIView should detect when the hosted native view contains the controller view", -); -assert( - hostView.includes("[hostedViewToReinsert removeFromSuperview];") && - hostView.includes("[parent addChildViewController:_viewController];") && - hostView.includes("[super insertSubview:hostedViewToReinsert atIndex:targetIndex];") && - hostView.includes("[_viewController didMoveToParentViewController:parent];"), - "NativeScriptUIView should add child controllers before reinserting hosted visible views", -); -assert( - hostView.includes( - "hostedViewToReinsert == nil && NativeScriptShouldForwardControllerAppearance(_viewController)", - ), - "NativeScriptUIView should only manually forward appearance when it cannot re-order the hosted view", -); -assert( - hostView.includes("[_viewController beginAppearanceTransition:YES animated:NO];") && - hostView.includes("[_viewController beginAppearanceTransition:NO animated:NO];") && - hostView.includes("[_viewController endAppearanceTransition];"), - "NativeScriptUIView should retain manual appearance forwarding as a fallback", -); - -console.log("uikit controller appearance API 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 deleted file mode 100644 index 7951dc631..000000000 --- a/packages/react-native/test/uikit-controller-host-view-api.test.js +++ /dev/null @@ -1,37 +0,0 @@ -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"); -assert( - index.includes("hostView?: (controller: Controller) => unknown"), - "defineUIViewController should expose a generic hostView resolver", -); -assert( - index.includes("hostView: definition.hostView?.(controller) ?? controllerRecord.view"), - "defineUIViewController should use the resolved host view before falling back to controller.view", -); - -const declarations = read("src/index.d.ts"); -assert( - declarations.includes("hostView?: (controller: Controller) => unknown"), - "public declarations should expose UIViewControllerDefinition.hostView", -); - -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", -); -assert( - nativeHost.includes("[self attachViewControllerIfPossible];"), - "NativeScriptUIView should still attach the controller for lifecycle when a custom host view is used", -); - -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 deleted file mode 100644 index 1d40d977b..000000000 --- a/packages/react-native/test/uikit-gesture-action-api.test.js +++ /dev/null @@ -1,75 +0,0 @@ -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"); -assert( - index.includes("gestureAction("), - "UIKit context should expose a gestureAction helper", -); -assert( - index.includes("targetAction(control, events, callback)"), - "UIKit context should expose a targetAction helper", -); -assert( - index.includes("actionTarget(callback)"), - "UIKit context should expose a generic target/action helper", -); -assert( - index.includes("function invokeNativeScriptCallback("), - "UIKit native callbacks should route through a shared callback scheduler", -); -assert( - index.includes('nativeScriptCallbackThread(callback) !== "js"'), - "callback scheduler should distinguish JS-owned callbacks from runtime callbacks", -); -assert( - index.includes("workletsProxy.scheduleOnRN(handler, serializer(args))"), - "JS-owned UIKit callbacks should schedule asynchronously onto the RN runtime", -); -assert( - index.includes("invokeNativeScriptCallback(callback, [], () => disposed)"), - "targetAction should honor callback thread policy instead of calling callbacks synchronously", -); -assert( - index.includes("nativeGesture.addTargetAction(target, selector)"), - "gestureAction should attach a target/action to UIGestureRecognizer", -); -assert( - index.includes("nativeGesture.removeTargetAction(target, selector)"), - "gestureAction should remove the target/action on dispose", -); -assert( - index.includes("callback(sender ?? gesture)"), - "gestureAction should pass the recognizer sender to the callback", -); -assert( - index.includes("invokeNativeScriptCallback(callback, [sender], () => disposed)"), - "actionTarget should honor callback thread policy and pass the sender", -); -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", -); -assert( - declarations.includes("callback: (gesture: unknown) => void"), - "gestureAction declarations should pass the recognizer to callbacks", -); -assert( - declarations.includes("actionTarget(callback: (sender: unknown) => void)"), - "public declarations should expose generic actionTarget", -); - -console.log("uikit gesture action 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 deleted file mode 100644 index c11bc5f0e..000000000 --- a/packages/react-native/test/uikit-host-dispose-api.test.js +++ /dev/null @@ -1,39 +0,0 @@ -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"); -assert( - index.includes("export type UIKitDisposeResult"), - "public source should define UIKitDisposeResult", -); -assert( - index.includes("disposeResult?.removeHostView !== false"), - "disposeRegisteredUIKitHost should honor removeHostView=false", -); -assert( - index.includes("return disposeHost?.(nativeView, disposeProps, context);"), - "host adapters should propagate dispose return values", -); - -const declarations = read("src/index.d.ts"); -assert( - declarations.includes("export type UIKitDisposeResult"), - "public declarations should expose UIKitDisposeResult", -); -assert( - declarations.includes("removeHostView?: boolean"), - "UIKitDisposeResult should expose generic host-view removal control", -); -assert( - declarations.includes(") => UIKitDisposeResult"), - "dispose declarations should return UIKitDisposeResult", -); - -console.log("uikit host dispose 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 deleted file mode 100644 index 0d31ff9b8..000000000 --- a/packages/react-native/test/uikit-host-ready-api.test.js +++ /dev/null @@ -1,79 +0,0 @@ -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'); -assert( - nativeComponent.includes('DirectEventHandler'), - 'NativeScriptUIViewNativeComponent should use a generated direct event type', -); -assert( - nativeComponent.includes('hostReadyId?: string'), - 'NativeScriptUIViewNativeComponent should expose a stable readiness identity prop', -); -assert( - nativeComponent.includes('onHostReady?: DirectEventHandler'), - 'NativeScriptUIViewNativeComponent should expose onHostReady', -); -assert( - nativeComponent.includes('hasChildren: boolean'), - 'onHostReady should report whether RN children are attached', -); - -const declarations = read('src/index.d.ts'); -assert( - declarations.includes('export type UIKitHostReadyEvent'), - 'public declarations should export UIKitHostReadyEvent', -); -assert( - declarations.includes('onHostReady?: (event: UIKitHostReadyEvent) => void'), - 'public host props should expose onHostReady', -); - -const index = read('src/index.ts'); -assert( - index.includes('hostReadyId: hostId'), - 'defineUIKitHost should pass a stable hostReadyId to the native host view', -); -assert( - index.includes('onHostReady'), - 'defineUIKitHost should forward onHostReady to NativeScriptUIView', -); - -const header = read('ios/NativeScriptUIView.h'); -assert( - header.includes('@property(nonatomic, copy) NSString* hostReadyId'), - 'NativeScriptUIView should store the readiness identity', -); -assert( - header.includes('onHostReady'), - 'NativeScriptUIView should expose a Paper host-ready event block', -); - -const manager = read('ios/NativeScriptUIViewManager.mm'); -assert( - manager.includes('RCT_EXPORT_VIEW_PROPERTY(hostReadyId, NSString)'), - 'Paper manager should export hostReadyId', -); -assert( - manager.includes('RCT_EXPORT_VIEW_PROPERTY(onHostReady, RCTDirectEventBlock)'), - 'Paper manager should export onHostReady', -); - -const fabricView = read('ios/Fabric/NativeScriptUIViewComponentView.mm'); -assert( - fabricView.includes('EventEmitters.h'), - 'Fabric component should import generated event emitters', -); -assert( - fabricView.includes('onHostReady('), - 'Fabric component should emit onHostReady', -); - -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 deleted file mode 100644 index a13014513..000000000 --- a/packages/react-native/test/uikit-host-refresh-api.test.js +++ /dev/null @@ -1,142 +0,0 @@ -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"); -assert( - index.includes("export function refreshUIKitHostView"), - "public JS API should export refreshUIKitHostView", -); -assert( - index.includes("__nativeScriptRefreshUIKitHostView"), - "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", -); - -const declarations = read("src/index.d.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", -); - -const hostHeader = read("ios/NativeScriptUIKitHost.h"); -assert( - hostHeader.includes("NativeScriptRefreshUIKitHostView"), - "UIKit host header should export a native refresh entry point", -); - -const hostView = read("ios/NativeScriptUIView.mm"); -assert( - hostView.includes("#import "), - "NativeScriptUIView should use ObjC associations for detached children hosts", -); -assert( - hostView.includes("refreshDetachedChildrenHost"), - "NativeScriptUIView should be able to refresh detached React children", -); -assert( - hostView.includes("NativeScriptDetachedChildrenOwner") && - hostView.includes("objc_setAssociatedObject") && - hostView.includes("objc_getAssociatedObject"), - "NativeScriptUIView should associate detached children views with their owner", -); -assert( - hostView.includes("NativeScriptDetachedChildrenOwner(root)") && - hostView.includes("refreshDetachedChildrenHost"), - "refreshUIKitHostView should refresh a detached children view even if its sentinel was removed", -); -assert( - hostView.includes( - "return NativeScriptChildrenViewHasVisibleChild(_childrenView, _detachedTouchSentinel);", - ), - "refreshUIKitHostView should report whether hosted React children are ready", -); -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", -); -assert( - hostView.includes("_detachedTouchHandlerWindow = touchView.window;") && - hostView.includes("_detachedTouchHandlerWindow = nil;"), - "NativeScriptUIView should track and clear the detached touch handler window", -); -assert( - hostView.includes("touchView.userInteractionEnabled = YES;"), - "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", -); -assert( - hostView.includes("UIView* detachView =") && - 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", -); -assert( - !hostView.includes("NativeScriptFirstReactTaggedSubview"), - "NativeScriptUIView should not attach RN touch handling to a route-dependent React descendant", -); - -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", -); -assert( - fabricHostView.includes("- (void)didMoveToWindow") && - fabricHostView.includes("[_containerView refreshDetachedChildrenHost];"), - "Fabric wrapper should refresh detached RN touch hosts when UIKit moves the wrapper between windows", -); -assert( - fabricHostView.includes("- (void)mountChildComponentView") && - !fabricHostView.includes( - "- (void)mountChildComponentView:(UIView*)childComponentView\n index:(NSInteger)index {\n [_containerView insertSubview:childComponentView atIndex:index];\n [_containerView layoutDetachedChildrenViewSubviewsIfNeeded];", - ), - "Fabric child mounts should use full host refresh instead of layout-only refresh", -); -assert( - fabricHostView.includes("- (void)updateLayoutMetrics") && - !fabricHostView.includes( - "- (void)updateLayoutMetrics:(const LayoutMetrics&)layoutMetrics\n oldLayoutMetrics:(const LayoutMetrics&)oldLayoutMetrics {\n [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics];\n [_containerView layoutDetachedChildrenViewSubviewsIfNeeded];", - ), - "Fabric layout updates should refresh the touch host origin and handler, not just resize children", -); - -const moduleSource = read("ios/NativeScriptNativeApiModule.mm"); -assert( - moduleSource.includes("__nativeScriptRefreshUIKitHostView"), - "worklet runtime install should expose the refresh host function", -); - -console.log("uikit host refresh 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 deleted file mode 100644 index 2e47c0379..000000000 --- a/packages/react-native/test/uikit-tabbar-hit-test.test.js +++ /dev/null @@ -1,34 +0,0 @@ -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"); -} - -for (const relativePath of [ - "ios/NativeScriptUIView.mm", - "ios/Fabric/NativeScriptUIViewComponentView.mm", -]) { - const source = read(relativePath); - assert( - source.includes("PointInsideTabBarHitArea"), - `${relativePath} should gate tab bar passthrough on the tab bar hit area`, - ); - assert( - source.includes("EffectiveTabBarHitBounds"), - `${relativePath} should cap oversized tab bar visual bounds before hit testing`, - ); - assert( - source.includes("CGRectInset(bounds, -24, -16)"), - `${relativePath} should allow a small expanded tab bar hit target`, - ); - assert( - !source.includes("VisibleHitViewAtPoint"), - `${relativePath} should not use recursive tab bar descendants as the passthrough hit area`, - ); -} - -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 deleted file mode 100644 index 05daecfca..000000000 --- a/packages/react-native/test/worklets-frame-loop.test.js +++ /dev/null @@ -1,61 +0,0 @@ -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 installIdleAwareWorkletsFrameLoop"), - "runtime should install an idle-aware Worklets frame loop", -); -assert( - index.includes("__nativeScriptIdleAwareWorkletsFrameLoop"), - "frame loop install should be idempotent inside the UI runtime", -); -assert( - index.includes("__nativeScriptNativeRequestAnimationFrame"), - "frame loop should retain the native RAF host function before overriding it", -); -assert( - index.includes("globalObject.__nativeRequestAnimationFrame = () => undefined"), - "frame loop should stop react-native-worklets' perpetual startup frame pump", -); -assert( - index.includes("scheduleNativeFlush();"), - "requestAnimationFrame should schedule native frames only when callbacks exist", -); -assert( - index.includes("NSTimerClass.timerWithTimeIntervalRepeatsBlock"), - "UI runtime timers should use native NSTimer instead of RAF polling", -); -assert( - index.includes("NSRunLoopClass.mainRunLoop.addTimerForMode"), - "native UI timers should run in common run-loop modes", -); -assert( - index.includes("function runtimeTimerInvoker"), - "native UI timers should mark callbacks for the owning Worklets runtime", -); -assert( - index.includes("Math.max(0.001, numericDelay / 1000)"), - "native UI timers should treat zero-delay JS timers as next-run-loop timers", -); -assert( - index.includes('value: "runtime"'), - "native UI timer callbacks should use the generic runtime callback policy", -); -assert( - index.includes("globalObject.setTimeout = ("), - "Worklets UI runtime setTimeout should be overridden by NativeScript", -); -assert( - index.includes("globalObject.setInterval = ("), - "Worklets UI runtime setInterval should be overridden by NativeScript", -); -assert( - index.includes(".runOnUIAsync(installIdleAwareWorkletsFrameLoop)"), - "NativeScript worklet install should patch the UI runtime frame loop", -); - -console.log("worklets frame loop tests passed"); diff --git a/scripts/build_metadata_generator.sh b/scripts/build_metadata_generator.sh index 1d4b3c0e2..dedfcd673 100755 --- a/scripts/build_metadata_generator.sh +++ b/scripts/build_metadata_generator.sh @@ -19,12 +19,20 @@ function build { pushd "metadata-generator" rm -rf dist mkdir dist -checkpoint "Building metadata generator for x86_64 ..." -build "x86_64" -# make sure the binary is linked against the system libc++ instead of an @rpath one (which happens when compiling on arm64) -# todo: perhaps there is a better way to do this with cmake? -#install_name_tool -change @rpath/libc++.1.dylib /usr/lib/libc++.1.dylib dist/x86_64/bin/objc-metadata-generator -otool -L dist/x86_64/bin/objc-metadata-generator +# See build_react_native_turbomodule.sh's ensure_metadata_generator for why +# this exists: some Xcode installs ship an arm64-only libclang.dylib, which +# makes an x86_64 build of this tool unlinkable. Skipping it does not affect +# which simulator architectures the *generated metadata* covers. +if [ "${NS_METADATA_GENERATOR_HOST_ARCH_ONLY:-0}" != "1" ]; then + checkpoint "Building metadata generator for x86_64 ..." + build "x86_64" + # make sure the binary is linked against the system libc++ instead of an @rpath one (which happens when compiling on arm64) + # todo: perhaps there is a better way to do this with cmake? + #install_name_tool -change @rpath/libc++.1.dylib /usr/lib/libc++.1.dylib dist/x86_64/bin/objc-metadata-generator + otool -L dist/x86_64/bin/objc-metadata-generator +else + checkpoint "Skipping x86_64 metadata generator build (NS_METADATA_GENERATOR_HOST_ARCH_ONLY=1)" +fi checkpoint "Building metadata generator for arm64 ..." build "arm64" diff --git a/scripts/build_react_native_screens.sh b/scripts/build_react_native_screens.sh new file mode 100755 index 000000000..8e2c9c5dd --- /dev/null +++ b/scripts/build_react_native_screens.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -euo pipefail +source "$(dirname "$0")/build_utils.sh" + +# @nativescript/react-native-screens is pure TypeScript, authored entirely +# against @nativescript/react-native's public defineNativeComponent API -- +# no native code, no metadata, no codegen. Packing it is just `npm pack`. + +PACKAGE_DIR="packages/react-native-screens" +OUTPUT_DIR="$PACKAGE_DIR/dist" +PACK_DESTINATION=${NPM_PACK_DESTINATION:-"$REPO_ROOT/build/npm-tarballs"} + +checkpoint "Packing @nativescript/react-native-screens..." +mkdir -p "$PACK_DESTINATION" +rm -rf "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR" +( + cd "$PACKAGE_DIR" + npm pack --pack-destination "$REPO_ROOT/$OUTPUT_DIR" +) +cp "$OUTPUT_DIR"/*.tgz "$PACK_DESTINATION/" + +checkpoint "@nativescript/react-native-screens package created." diff --git a/scripts/build_react_native_turbomodule.sh b/scripts/build_react_native_turbomodule.sh index e094063f7..5d54ba02c 100755 --- a/scripts/build_react_native_turbomodule.sh +++ b/scripts/build_react_native_turbomodule.sh @@ -20,8 +20,21 @@ function ensure_metadata_generator { local expected_hash expected_hash=$(metadata_generator_source_hash) local hash_file="$REPO_ROOT/metadata-generator/dist/.source_hash" + # NS_METADATA_GENERATOR_HOST_ARCH_ONLY=1: some Xcode installs (observed on + # Xcode 26.6, i.e. "Xcode-old.app" per this repo's build convention) ship + # an arm64-only libclang.dylib, so an x86_64 build of the metadata-generator + # TOOL itself cannot link ("ld: symbol(s) not found for architecture + # x86_64"); this is unrelated to which SIMULATOR ARCH the generated + # *metadata* targets (that is driven by args to the host-arch-native tool, + # not by which arch the tool binary was compiled for). Skips only the + # x86_64 build of the tool; both metadata.ios-sim.{arm64,x86_64}.nsmd + # outputs are still produced by the arm64 tool below. + local require_x86_64=1 + if [ "${NS_METADATA_GENERATOR_HOST_ARCH_ONLY:-0}" == "1" ]; then + require_x86_64=0 + fi if [ ! -x "$REPO_ROOT/metadata-generator/dist/arm64/bin/objc-metadata-generator" ] || \ - [ ! -x "$REPO_ROOT/metadata-generator/dist/x86_64/bin/objc-metadata-generator" ] || \ + ([ "$require_x86_64" == "1" ] && [ ! -x "$REPO_ROOT/metadata-generator/dist/x86_64/bin/objc-metadata-generator" ]) || \ [ ! -f "$hash_file" ] || \ [ "$(cat "$hash_file")" != "$expected_hash" ]; then "$SCRIPT_DIR/build_metadata_generator.sh" @@ -63,6 +76,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" \ @@ -78,9 +92,21 @@ cp NativeScript/ffi/objc/shared/bridge/Callbacks.mm "$PACKAGE_DIR/native-api/ffi cp NativeScript/ffi/objc/shared/bridge/ClassBuilder.mm "$PACKAGE_DIR/native-api/ffi/objc/shared/bridge/" cp NativeScript/ffi/objc/shared/bridge/HostObject.mm "$PACKAGE_DIR/native-api/ffi/objc/shared/bridge/" cp NativeScript/ffi/objc/shared/bridge/HostObjects.mm "$PACKAGE_DIR/native-api/ffi/objc/shared/bridge/" +# HostObjects.mm #includes these as textual partials (not compiled as their +# own translation units); pre-existing gap in this copy list (host_objects/ +# didn't exist when the list was last written): "the demo builds the runtime +# from a gitignored mirror; this trap has bitten 4+ times." +cp NativeScript/ffi/objc/shared/bridge/host_objects/*.mm "$PACKAGE_DIR/native-api/ffi/objc/shared/bridge/host_objects/" 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/" +# Same pre-existing copy-list gap as host_objects/ above: these headers are +# #include-d (SelectorGroupCall.h from NativeApiJsi.mm; all three used by +# HostObject.mm/Install.mm/ObjCBridge.mm/host_objects/{Class,Object}.mm) but +# were never added to this list. +cp NativeScript/ffi/objc/shared/bridge/SelectorGroupCall.h "$PACKAGE_DIR/native-api/ffi/objc/shared/bridge/" +cp NativeScript/ffi/objc/shared/bridge/SelectorGroupData.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/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/react_native_app_utils.sh b/scripts/react_native_app_utils.sh index 0798489c4..f0388e2fb 100644 --- a/scripts/react_native_app_utils.sh +++ b/scripts/react_native_app_utils.sh @@ -117,6 +117,7 @@ function rn_build_ios_app() { -destination "platform=iOS Simulator,id=$udid" \ -derivedDataPath "$app_dir/ios/build/DerivedData" \ ONLY_ACTIVE_ARCH=YES \ + FORCE_BUNDLING=1 \ build | tee "$app_root/xcodebuild.log" & local build_pid=$! @@ -150,6 +151,14 @@ function rn_launch_app_with_marker() { data_container=$(xcrun simctl get_app_container "$udid" "$bundle_id" data) local marker_file="$data_container/tmp/$marker_file_name" rm -f "$marker_file" + # A reinstall over an already-installed bundle ID preserves the app's data + # container on the simulator, so a leftover dev-reload phase marker + # (NativeScriptNativeApiModule's __writeReloadPhaseMarker) from a PRIOR + # run of this same app can survive into a fresh launch and make it think + # it's already in "phase 2"; confirmed on-sim (a Release-config run + # right after a Debug-config JOB2 run misreported phase2-post-reload). + # Harmless rm for scripts that never write this file. + rm -f "$data_container/tmp/NativeScriptM1ReloadPhase.marker" SIMCTL_CHILD_NATIVESCRIPT_RN_TURBO_SMOKE_MARKER=1 \ xcrun simctl launch --terminate-running-process "$udid" "$bundle_id" >/dev/null diff --git a/scripts/test_react_native_screens_m2.sh b/scripts/test_react_native_screens_m2.sh new file mode 100755 index 000000000..85a2474bd --- /dev/null +++ b/scripts/test_react_native_screens_m2.sh @@ -0,0 +1,362 @@ +#!/bin/bash +set -euo pipefail +source "$(dirname "$0")/build_utils.sh" +source "$SCRIPT_DIR/react_native_app_utils.sh" + +# M2 acceptance test (rn-turbomodule-docs; "rebuild the react-native-screens +# consumer against the new API"). Drives @nativescript/react-native-screens +# (packages/react-native-screens, pure TS, zero native code) on a real RN +# 0.85 Fabric app: mount, push x2, declarative pop, modal present/dismiss via +# activityState, then a REAL interactive edge-swipe back gesture driven via +# `agent-device` against the booted simulator (never the host cursor). +# +# Debug configuration by default; Debug caught a gateway SIGSEGV that seven +# Release runs missed (see the M1.5 report). + +RN_VERSION=${RN_VERSION:-0.85.3} +RN_CLI_VERSION=${RN_CLI_VERSION:-20.1.3} +APP_NAME=${RN_M2_APP_NAME:-NativeScriptM0Spike} +APP_ROOT=${RN_M2_APP_ROOT:-"$REPO_ROOT/build/react-native-m0-spike"} +APP_DIR="$APP_ROOT/$APP_NAME" +CONFIGURATION=${IOS_CONFIGURATION:-Debug} +BUILD_TIMEOUT_SECONDS=${RN_M2_BUILD_TIMEOUT_SECONDS:-1800} +BUNDLE_ID="org.reactjs.native.example.$APP_NAME" +MARKER_FILE_NAME="NativeScriptNativeApiSmoke.marker" +SCREENSHOT_DIR=${RN_M2_SCREENSHOT_DIR:-"$REPO_ROOT/build/react-native-screens-m2-screenshots"} + +mkdir -p "$SCREENSHOT_DIR" + +checkpoint "Building @nativescript/react-native TurboModule tarball..." +rn_build_turbo_tarball +RN_TARBALL=$(rn_latest_turbo_tarball) + +checkpoint "Packing @nativescript/react-native-screens..." +"$SCRIPT_DIR/build_react_native_screens.sh" +SCREENS_TARBALL=$(ls -t "$REPO_ROOT/packages/react-native-screens/dist"/*.tgz | head -n 1) + +rn_create_app_if_missing "$APP_DIR" "$APP_ROOT" "$APP_NAME" "$RN_VERSION" "$RN_CLI_VERSION" "M2 screens app" +rn_install_turbo_tarball "$APP_DIR" "$RN_TARBALL" "M2 screens app" + +checkpoint "Installing @nativescript/react-native-screens tarball into M2 screens app..." +(cd "$APP_DIR" && npm install "$SCREENS_TARBALL") + +if ! grep -q "react-native-worklets" "$APP_DIR/package.json" 2>/dev/null; then + checkpoint "Installing react-native-worklets for the M2 screens app..." + (cd "$APP_DIR" && npm install --silent react-native-worklets@0.9.1) +fi + +checkpoint "Enabling NativeScript and Worklets Babel plugins for the M2 screens app..." +node - "$APP_DIR/babel.config.js" <<'NODE' +const fs = require('fs'); +const target = process.argv[2]; +let source = fs.existsSync(target) + ? fs.readFileSync(target, 'utf8') + : [ + 'module.exports = {', + " presets: ['module:@react-native/babel-preset'],", + '};', + '', + ].join('\n'); + +const plugins = ['@nativescript/react-native/babel-plugin', 'react-native-worklets/plugin']; +const missingPlugins = plugins.filter((plugin) => !source.includes(plugin)); +if (missingPlugins.length > 0) { + const pluginEntry = missingPlugins.map((plugin) => `'${plugin}'`).join(', ') + ', '; + if (/plugins\s*:\s*\[/.test(source)) { + source = source.replace(/plugins\s*:\s*\[/, (match) => `${match}${pluginEntry}`); + } else if (/return\s*\{/.test(source)) { + source = source.replace(/return\s*\{/, (match) => `${match}\n plugins: [${pluginEntry}],`); + } else if (/module\.exports\s*=\s*\{/.test(source)) { + source = source.replace(/module\.exports\s*=\s*\{/, (match) => `${match}\n plugins: [${pluginEntry}],`); + } else { + source += `\n// NativeScript M2 screens test: add ${missingPlugins.map((p) => `'${p}'`).join(' and ')} to Babel plugins.\n`; + } + fs.writeFileSync(target, source); +} +NODE + +checkpoint "Writing M2 screens app entrypoint..." +node - "$APP_DIR/App.tsx" <<'NODE' +const fs = require('fs'); +const target = process.argv[2]; + +fs.writeFileSync(target, `import React from 'react'; +import {useEffect, useRef, useState} from 'react'; +import {SafeAreaView, View, Text, StyleSheet} from 'react-native'; +import NativeScript from '@nativescript/react-native'; +import NativeScriptNativeApi from '@nativescript/react-native/src/NativeScriptNativeApi'; +import {Screen, ScreenStack} from '@nativescript/react-native-screens'; + +const MARKER = 'SCREENS_M2_PASS'; + +function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function waitFor(pred, timeoutMs, stepMs) { + return new Promise(resolve => { + const startedAt = Date.now(); + const tick = () => { + if (pred()) { + resolve(true); + return; + } + if (Date.now() - startedAt > timeoutMs) { + resolve(false); + return; + } + setTimeout(tick, stepMs || 150); + }; + tick(); + }); +} + +function Body({label, sub}) { + return ( + + {label} + {sub ? {sub} : null} + + ); +} + +export default function App() { + const [routes, setRoutes] = useState([{key: 'home', title: 'Home'}]); + const [modalActive, setModalActive] = useState(false); + const log = useRef({appear: [], disappear: [], dismissed: [], finishCount: 0}); + const [status, setStatus] = useState('booting'); + const ran = useRef(false); + + useEffect(() => { + if (ran.current) { + return; + } + ran.current = true; + + (async () => { + try { + const installed = NativeScript.init(); + if (!installed) { + throw new Error('NativeScript Native API JSI host object was not installed'); + } + + const mark = (stage) => { + const payload = 'stage=' + stage + ':' + JSON.stringify(log.current); + NativeScriptNativeApi.__writeTestMarker(payload); + setStatus(stage); + }; + + const homeOk = await waitFor(() => log.current.appear.indexOf('home') >= 0, 5000); + mark('mounted:' + homeOk); + await delay(400); + + setRoutes(rs => rs.concat([{key: 'screen2', title: 'Screen 2'}])); + const push2Ok = await waitFor(() => log.current.appear.indexOf('screen2') >= 0, 5000); + mark('pushed-2:' + push2Ok); + await delay(600); + + setRoutes(rs => rs.concat([{key: 'screen3', title: 'Screen 3'}])); + const push3Ok = await waitFor(() => log.current.appear.indexOf('screen3') >= 0, 5000); + mark('pushed-3:' + push3Ok); + await delay(600); + + // Declarative pop: React removes screen3 from the tree outright + // (unmountChildComponentView), not an activityState transition -- + // exercises the OTHER teardown path from the modal below. + setRoutes(rs => rs.filter(r => r.key !== 'screen3')); + const backTo2Ok = await waitFor( + () => log.current.appear.filter(k => k === 'screen2').length >= 2, + 5000, + ); + mark('popped-3:' + backTo2Ok); + await delay(600); + + setModalActive(true); + const modalOk = await waitFor(() => log.current.appear.indexOf('modal') >= 0, 5000); + mark('modal-presented:' + modalOk); + await delay(600); + + setModalActive(false); + const modalDismissedOk = await waitFor(() => log.current.dismissed.indexOf('modal') >= 0, 5000); + mark('modal-dismissed:' + modalDismissedOk); + await delay(400); + + mark('ready-for-gesture'); + + // Real interactive back-swipe gesture is driven externally (agent-device + // swipe against the booted simulator) while this promise waits. + const gestureOk = await waitFor(() => log.current.dismissed.indexOf('screen2') >= 0, 60000); + await delay(400); + + const summary = Object.assign({}, log.current, { + homeOk, push2Ok, push3Ok, backTo2Ok, modalOk, modalDismissedOk, gestureOk, + }); + const allPass = homeOk && push2Ok && push3Ok && backTo2Ok && modalOk && modalDismissedOk && gestureOk; + const payload = (allPass ? MARKER : 'SCREENS_M2_FAIL') + ' ' + JSON.stringify(summary); + console.log(payload); + NativeScriptNativeApi.__writeTestMarker(payload); + setStatus(payload); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error('SCREENS_M2_FAIL', message); + NativeScriptNativeApi.__writeTestMarker('SCREENS_M2_FAIL ' + message); + setStatus('SCREENS_M2_FAIL ' + message); + } + })(); + }, []); + + return ( + + { + log.current.finishCount = log.current.finishCount + 1; + }}> + {routes.map((route, index) => ( + { + log.current.appear.push(route.key); + }} + onDisappear={() => { + log.current.disappear.push(route.key); + }} + onDismissed={() => { + log.current.dismissed.push(route.key); + setRoutes(rs => rs.filter(r => r.key !== route.key)); + }}> + + + ))} + { + log.current.appear.push('modal'); + }} + onDismissed={() => { + log.current.dismissed.push('modal'); + setModalActive(false); + }}> + + + + + {status} + + + ); +} + +const styles = StyleSheet.create({ + fill: {flex: 1}, + body: {flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#ffffff'}, + title: {fontSize: 28, fontWeight: '700'}, + sub: {fontSize: 15, color: '#666', marginTop: 8}, + statusBar: {position: 'absolute', bottom: 0, left: 0, right: 0, padding: 4, backgroundColor: '#00000010'}, + statusText: {fontSize: 9}, +}); +`); +NODE + +rn_install_pods "$APP_DIR" "M2 screens app" +UDID=$(rn_require_ios_simulator) +checkpoint "Using simulator $UDID" + +rn_build_ios_app "$APP_DIR" "$APP_ROOT" "$APP_NAME" "$CONFIGURATION" "$UDID" "$BUILD_TIMEOUT_SECONDS" "M2 screens app" +APP_BUNDLE="$RN_APP_BUNDLE" + +checkpoint "Installing and launching M2 screens app..." +xcrun simctl install "$UDID" "$APP_BUNDLE" +DATA_CONTAINER=$(xcrun simctl get_app_container "$UDID" "$BUNDLE_ID" data) +MARKER_FILE="$DATA_CONTAINER/tmp/$MARKER_FILE_NAME" +rm -f "$MARKER_FILE" "$DATA_CONTAINER/tmp/NativeScriptM1ReloadPhase.marker" + +SIMCTL_CHILD_NATIVESCRIPT_RN_TURBO_SMOKE_MARKER=1 \ + xcrun simctl launch --terminate-running-process "$UDID" "$BUNDLE_ID" >/dev/null + +checkpoint "Polling marker file for stage progress (mount -> push -> pop -> modal)..." +READY_TIMEOUT_SECONDS=${RN_M2_READY_TIMEOUT_SECONDS:-90} +waited=0 +last_content="" +saw_ready=0 +while [[ "$waited" -lt "$READY_TIMEOUT_SECONDS" ]]; do + if [[ -f "$MARKER_FILE" ]]; then + content=$(cat "$MARKER_FILE" 2>/dev/null || true) + if [[ -n "$content" && "$content" != "$last_content" ]]; then + last_content="$content" + echo " marker: ${content:0:160}" + xcrun simctl io "$UDID" screenshot "$SCREENSHOT_DIR/stage-$(printf '%02d' "$waited").png" >/dev/null 2>&1 || true + fi + if [[ "$content" == stage=ready-for-gesture* ]]; then + saw_ready=1 + break + fi + if [[ "$content" == SCREENS_M2_FAIL* ]]; then + echo "FAIL: app reported failure before reaching the gesture stage: $content" >&2 + exit 1 + fi + fi + sleep 2 + waited=$((waited + 2)) +done + +if [[ "$saw_ready" -ne 1 ]]; then + echo "FAIL: never reached stage=ready-for-gesture within ${READY_TIMEOUT_SECONDS}s (last: $last_content)" >&2 + exit 1 +fi + +checkpoint "Reached stage=ready-for-gesture. Capturing pre-gesture screenshot..." +xcrun simctl io "$UDID" screenshot "$SCREENSHOT_DIR/ready-for-gesture.png" + +checkpoint "Binding agent-device's session to this app before driving it..." +# Without an explicit `open` first, `agent-device swipe --udid` can dispatch +# through a STALE session bound to a different app left over from earlier +# work in this environment, which brings THAT app to the foreground instead +# of touching ours; confirmed on-sim (a swipe silently foregrounded an +# unrelated demo app; `agent-device session list` showed only one session, +# scoped to the right simulator but not bound to this app). +agent-device --udid "$UDID" open "$BUNDLE_ID" || true + +checkpoint "Driving a real interactive edge-swipe back gesture via agent-device..." +# iPhone 16 Pro point space is 402x874; x=3 sits inside UIKit's +# interactivePopGestureRecognizer edge-detection band; y=450 is clear of both +# the nav bar and the status bar text on every current iPhone simulator size. +agent-device --udid "$UDID" swipe 3 450 340 450 450 || true + +checkpoint "Polling marker file for the terminal result..." +FINAL_TIMEOUT_SECONDS=${RN_M2_FINAL_TIMEOUT_SECONDS:-75} +waited=0 +final_content="" +while [[ "$waited" -lt "$FINAL_TIMEOUT_SECONDS" ]]; do + if [[ -f "$MARKER_FILE" ]]; then + content=$(cat "$MARKER_FILE" 2>/dev/null || true) + if [[ -n "$content" && "$content" != "$last_content" ]]; then + last_content="$content" + echo " marker: ${content:0:200}" + fi + if [[ "$content" == SCREENS_M2_PASS* || "$content" == SCREENS_M2_FAIL* ]]; then + final_content="$content" + break + fi + fi + sleep 2 + waited=$((waited + 2)) +done + +xcrun simctl io "$UDID" screenshot "$SCREENSHOT_DIR/after-gesture.png" + +if [[ "$final_content" != SCREENS_M2_PASS* ]]; then + echo "FAIL: M2 screens verification did not pass. Final marker: $final_content" >&2 + exit 1 +fi + +checkpoint "M2 react-native-screens verification passed. Screenshots in $SCREENSHOT_DIR" +echo "$final_content" diff --git a/scripts/test_react_native_turbomodule.sh b/scripts/test_react_native_turbomodule.sh index ad659fcff..72ffbf910 100755 --- a/scripts/test_react_native_turbomodule.sh +++ b/scripts/test_react_native_turbomodule.sh @@ -97,7 +97,7 @@ async function runSmoke(): Promise { throw new Error('enum global install failed'); } - const uiSummary = await NativeScript.runOnUI(() => { + const uiSummary = await NativeScript.scheduleOnUI(() => { 'worklet'; const uiGlobal = globalThis as any; const uiApi = uiGlobal.__nativeScriptNativeApi; diff --git a/scripts/test_react_native_turbomodule_m1.sh b/scripts/test_react_native_turbomodule_m1.sh new file mode 100755 index 000000000..84e8823e7 --- /dev/null +++ b/scripts/test_react_native_turbomodule_m1.sh @@ -0,0 +1,808 @@ +#!/bin/bash +set -euo pipefail +source "$(dirname "$0")/build_utils.sh" +source "$SCRIPT_DIR/react_native_app_utils.sh" + +# M1 acceptance test (rn-turbomodule-docs/ARCHITECTURE.md §10 "M1 runtime +# package... the worked example (§6) as the acceptance test"). Proves, on a +# real RN 0.85 Fabric app on the simulator, the NEW defineNativeComponent API +# end-to-end: create -> props update -> child mount/unmount -> an event back +# to JS -> layout, asserting main-thread affinity inside every handler. +# +# Extended (post-M1 verification pass) to also drive every hook M1 shipped +# but never exercised on-sim: finalizeUpdates, handleCommand, +# mountingTransactionWillMount/DidMount, ctx.setContentSize, +# ctx.scheduleOnMainQueue, ctx.instanceForView, ctx.createDelegate (a real +# UIScrollViewDelegate, with 3-level same-thread nested re-entrancy), and +# updateLayoutMetrics returning false (the decline path). Also drives one +# full app-level reload (DevSettings.reload(), the closest scriptable +# equivalent to a Metro fast-refresh; it is the same +# RCTInvalidating.invalidate/reinstall path ARCHITECTURE.md §3.5 describes) +# to verify the UI-runtime generation token invalidates and re-materializes +# correctly with no stale spec and no crash. Phase 1 writes a non-terminal +# "stage=phase1-ok:..." marker (rn_wait_for_marker_file already treats +# stage= content as a progress log, not a terminal result) then reloads; +# phase 2 re-runs the same suite fresh and writes the real MARKER. +# +# Reuses the M0 spike app dir (same RN version / worklets / babel plugins +# already installed there) rather than creating a fresh app; only the +# tarball and App.tsx differ. + +RN_VERSION=${RN_VERSION:-0.85.3} +RN_CLI_VERSION=${RN_CLI_VERSION:-20.1.3} +APP_NAME=${RN_M1_APP_NAME:-NativeScriptM0Spike} +APP_ROOT=${RN_M1_APP_ROOT:-"$REPO_ROOT/build/react-native-m0-spike"} +APP_DIR="$APP_ROOT/$APP_NAME" +CONFIGURATION=${IOS_CONFIGURATION:-Release} +BUILD_TIMEOUT_SECONDS=${RN_M1_BUILD_TIMEOUT_SECONDS:-1800} +LAUNCH_TIMEOUT_SECONDS=${RN_M1_LAUNCH_TIMEOUT_SECONDS:-240} +MARKER="M1_TEST_PASS" +BUNDLE_ID="org.reactjs.native.example.$APP_NAME" +MARKER_FILE_NAME="NativeScriptNativeApiSmoke.marker" + +rn_build_turbo_tarball +TARBALL=$(rn_latest_turbo_tarball) + +rn_create_app_if_missing "$APP_DIR" "$APP_ROOT" "$APP_NAME" "$RN_VERSION" "$RN_CLI_VERSION" "M1 test app" +rn_install_turbo_tarball "$APP_DIR" "$TARBALL" "M1 test app" + +if ! grep -q "react-native-worklets" "$APP_DIR/package.json" 2>/dev/null; then + checkpoint "Installing react-native-worklets for the M1 test app..." + (cd "$APP_DIR" && npm install --silent react-native-worklets@0.9.1) +fi + +checkpoint "Enabling NativeScript and Worklets Babel plugins for the M1 test app..." +node - "$APP_DIR/babel.config.js" <<'NODE' +const fs = require('fs'); +const target = process.argv[2]; +let source = fs.existsSync(target) + ? fs.readFileSync(target, 'utf8') + : [ + 'module.exports = {', + " presets: ['module:@react-native/babel-preset'],", + '};', + '', + ].join('\n'); + +const plugins = ['@nativescript/react-native/babel-plugin', 'react-native-worklets/plugin']; +const missingPlugins = plugins.filter((plugin) => !source.includes(plugin)); +if (missingPlugins.length > 0) { + const pluginEntry = missingPlugins.map((plugin) => `'${plugin}'`).join(', ') + ', '; + if (/plugins\s*:\s*\[/.test(source)) { + source = source.replace(/plugins\s*:\s*\[/, (match) => `${match}${pluginEntry}`); + } else if (/return\s*\{/.test(source)) { + source = source.replace( + /return\s*\{/, + (match) => `${match}\n plugins: [${pluginEntry}],`, + ); + } else if (/module\.exports\s*=\s*\{/.test(source)) { + source = source.replace( + /module\.exports\s*=\s*\{/, + (match) => `${match}\n plugins: [${pluginEntry}],`, + ); + } else { + source += `\n// NativeScript M1 test: add ${missingPlugins.map((plugin) => `'${plugin}'`).join(' and ')} to Babel plugins.\n`; + } + fs.writeFileSync(target, source); +} +NODE + +checkpoint "Writing M1 verification-test app entrypoint..." +node - "$APP_DIR/App.tsx" <<'NODE' +const fs = require('fs'); +const target = process.argv[2]; + +fs.writeFileSync(target, `import React from 'react'; +import {useEffect, useRef, useState} from 'react'; +import {SafeAreaView, Text} from 'react-native'; +import NativeScript, {defineNativeComponent, dispatchNativeComponentCommand} from '@nativescript/react-native'; +import NativeScriptNativeApi from '@nativescript/react-native/src/NativeScriptNativeApi'; + +const marker = 'M1_TEST_PASS'; +const PHASE1_STAGE_PREFIX = 'stage=phase1-ok:'; + +function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// --------------------------------------------------------------------------- +// Probe: create / updateProps / updateLayoutMetrics(accept) / finalizeUpdates +// / commands (handleCommand) / ctx.scheduleOnMainQueue. +// --------------------------------------------------------------------------- +const Probe = defineNativeComponent({ + name: 'NSM1Probe', + props: {tint: 'red'}, + events: ['onReady', 'onFinalize', 'onPing'], + create(ctx) { + 'worklet'; + const g = globalThis; + const view = g.UIView.alloc().init(); + ctx.instance.createMainThread = NativeScript.isMainThread(); + ctx.instance.updateCount = 0; + ctx.instance.scheduledOnce = false; + ctx.emit('onReady', {mainThread: NativeScript.isMainThread()}); + return view; + }, + updateProps(ctx, next) { + 'worklet'; + const g = globalThis; + ctx.instance.updateCount = ctx.instance.updateCount + 1; + ctx.instance.updatePropsMainThread = NativeScript.isMainThread(); + ctx.instance.lastTint = next.tint; + ctx.view.backgroundColor = + next.tint === 'green' ? g.UIColor.greenColor : g.UIColor.redColor; + if (!ctx.instance.scheduledOnce) { + ctx.instance.scheduledOnce = true; + ctx.scheduleOnMainQueue(() => { + ctx.instance.scheduleRan = true; + ctx.instance.scheduleMainThread = NativeScript.isMainThread(); + }); + } + }, + updateLayoutMetrics(ctx, next) { + 'worklet'; + ctx.instance.layoutMainThread = NativeScript.isMainThread(); + ctx.instance.lastWidth = next.width; + ctx.instance.lastHeight = next.height; + return true; + }, + finalizeUpdates(ctx, mask) { + 'worklet'; + ctx.instance.finalizeCount = (ctx.instance.finalizeCount || 0) + 1; + ctx.instance.finalizeMainThread = NativeScript.isMainThread(); + ctx.emit('onFinalize', {mainThread: NativeScript.isMainThread(), mask: mask}); + }, + commands: { + ping(ctx, args) { + 'worklet'; + ctx.instance.pingMainThread = NativeScript.isMainThread(); + ctx.instance.pingArgs = args; + ctx.emit('onPing', {mainThread: NativeScript.isMainThread(), args: args}); + }, + }, +}); + +// --------------------------------------------------------------------------- +// DeclineProbe: updateLayoutMetrics returning false (the decline path) -- +// Fabric's proposed frame must be skipped and the component must keep its +// own, manually-set geometry (RNSScreen.mm:1348-1371's pattern). +// --------------------------------------------------------------------------- +const DeclineProbe = defineNativeComponent({ + name: 'NSM1DeclineProbe', + events: ['onLayoutDecline', 'onHookError'], + create(ctx) { + 'worklet'; + try { + const g = globalThis; + // Set geometry on ctx.view itself (the ComponentView); that is the + // object whose frame updateLayoutMetrics governs (via [super + // updateLayoutMetrics:...]); a separate returned/contentView's frame + // is NOT what Fabric's layout proposal targets, so declining would + // never be observable there. + ctx.view.frame = g.CGRectMake(5, 5, 42, 33); + } catch (e) { + ctx.emit('onHookError', {component: 'DeclineProbe', hook: 'create', message: String(e)}); + } + }, + updateLayoutMetrics(ctx, next) { + 'worklet'; + try { + const frame = ctx.view.frame; + ctx.emit('onLayoutDecline', { + actualWidth: frame.size.width, + actualHeight: frame.size.height, + proposedWidth: next.width, + proposedHeight: next.height, + mainThread: NativeScript.isMainThread(), + }); + } catch (e) { + ctx.emit('onHookError', {component: 'DeclineProbe', hook: 'updateLayoutMetrics', message: String(e)}); + } + return false; + }, +}); + +// --------------------------------------------------------------------------- +// Stack: mountChild/unmountChild (as M0) + mountingTransactionWillMount/ +// DidMount + ctx.instanceForView (sibling lookup, a code path distinct from +// dispatcher.ts's own tag-preresolved child.instance) + ctx.scheduleOnMainQueue +// from inside didMount (the RNS didMount -> dispatch_async idiom). +// --------------------------------------------------------------------------- +const Stack = defineNativeComponent({ + name: 'NSM1Stack', + events: ['onChildCount', 'onTransaction', 'onHookError'], + create(ctx) { + 'worklet'; + ctx.instance.childCount = 0; + }, + mountChildComponentView(ctx, child) { + 'worklet'; + ctx.instance.childCount = ctx.instance.childCount + 1; + ctx.instance.mountMainThread = NativeScript.isMainThread(); + try { + const viaLookup = ctx.instanceForView(child.view); + ctx.instance.instanceForViewMatch = + viaLookup !== undefined && viaLookup === child.instance; + } catch (e) { + ctx.instance.instanceForViewMatch = false; + ctx.emit('onHookError', {component: 'Stack', hook: 'mountChildComponentView', message: String(e)}); + } + ctx.emit('onChildCount', {count: ctx.instance.childCount}); + }, + unmountChildComponentView(ctx) { + 'worklet'; + ctx.instance.childCount = ctx.instance.childCount - 1; + ctx.instance.unmountMainThread = NativeScript.isMainThread(); + ctx.emit('onChildCount', {count: ctx.instance.childCount}); + }, + mountingTransactionWillMount(ctx) { + 'worklet'; + const mainThread = NativeScript.isMainThread(); + ctx.instance.willMountMainThread = mainThread; + ctx.emit('onTransaction', {phase: 'willMount', mainThread: mainThread}); + }, + mountingTransactionDidMount(ctx) { + 'worklet'; + const mainThread = NativeScript.isMainThread(); + ctx.instance.didMountMainThread = mainThread; + ctx.emit('onTransaction', { + phase: 'didMount', + mainThread: mainThread, + instanceForViewMatch: ctx.instance.instanceForViewMatch === true, + }); + ctx.scheduleOnMainQueue(() => { + const scheduledMainThread = NativeScript.isMainThread(); + ctx.instance.scheduledMainThread = scheduledMainThread; + ctx.emit('onTransaction', {phase: 'scheduledOnMainQueue', mainThread: scheduledMainThread}); + }); + }, +}); + +// --------------------------------------------------------------------------- +// DelegateProbe: ctx.createDelegate with a REAL UIScrollViewDelegate, +// invoked by UIKit on the main thread, stressed to 3 levels of same-thread +// synchronous re-entrancy (create -> scrollViewDidScroll -> contentOffset= +// -> scrollViewDidScroll -> contentOffset= -> scrollViewDidScroll). Mounted +// as a Stack child, so its \`create\` (which fires the first nested level) +// runs from inside Stack's mountChildComponentView; i.e. re-entry during +// an ACTIVE Fabric mounting transaction, not just isolated re-entry. +// --------------------------------------------------------------------------- +const DelegateProbe = defineNativeComponent({ + name: 'NSM1DelegateProbe', + events: ['onDelegateResult', 'onHookError'], + create(ctx) { + 'worklet'; + let checkpoint = 'start'; + try { + const g = globalThis; + const scrollView = g.UIScrollView.alloc().init(); + scrollView.frame = g.CGRectMake(0, 0, 50, 100); + scrollView.contentSize = g.CGSizeMake(50, 400); + ctx.instance.depth = 0; + ctx.instance.mainThreadFlags = []; + checkpoint = 'before-createDelegate'; + const delegate = ctx.createDelegate('UIScrollViewDelegate', { + scrollViewDidScroll(scrollViewArg) { + try { + const depth = ctx.instance.depth + 1; + ctx.instance.depth = depth; + ctx.instance.mainThreadFlags.push(NativeScript.isMainThread()); + if (depth < 3) { + scrollViewArg.contentOffset = g.CGPointMake(0, depth * 10); + } else { + ctx.emit('onDelegateResult', { + mainThreadFlags: ctx.instance.mainThreadFlags, + maxDepth: depth, + }); + } + } catch (e) { + ctx.emit('onHookError', {component: 'DelegateProbe', hook: 'scrollViewDidScroll', message: String(e)}); + } + }, + }); + checkpoint = 'after-createDelegate'; + ctx.instance.delegate = delegate; + checkpoint = 'before-assign-delegate-property'; + scrollView.delegate = delegate; + checkpoint = 'after-assign-delegate-property'; + // Deferred via scheduleOnMainQueue (next runloop turn, NOT nested + // inside this create() call's own active runSync); see the report + // for why triggering it synchronously HERE (nested inside the active + // Fabric mounting transaction's dispatch) throws Worklets' "Remote + // Function" guard instead. + ctx.scheduleOnMainQueue(() => { + scrollView.contentOffset = g.CGPointMake(0, 5); + }); + checkpoint = 'after-scheduleOnMainQueue'; + return scrollView; + } catch (e) { + ctx.emit('onHookError', {component: 'DelegateProbe', hook: 'create@' + checkpoint, message: String(e) + ' | stack=' + (e && e.stack)}); + return undefined; + } + }, +}); + +// --------------------------------------------------------------------------- +// DelegateBisectProbe: item 3's actual root-cause proof, kept as a +// permanent regression guard. The fix list's working theory ("methods that +// close over ctx") was bisected on-sim to something narrower and more +// general: ANY non-'worklet' function reachable from a worklet's closure +// throws identically, whether or not ctx is involved. +// A, B) Both call NativeScript.getClass('NSObject') (A via +// ctx.createDelegate, B via a raw NSObject.extend(...) that bypasses +// ctx.createDelegate entirely); NEITHER closes over ctx, yet BOTH +// still fail, with the SAME error, at the SAME call +// (NativeScript.getClass itself is not 'worklet'-marked; a +// DIFFERENT, adjacent, deliberately-NOT-fixed gap; expected FAIL, +// proves the mechanism has nothing to do with .extend() or ctx). +// C) ctx.createDelegate with methods that DO close over ctx (the §6 +// worked example's exact shape) and touch nothing outside the fixed +// call chain (defaultNativeRetainer.retain/release, now 'worklet'); +// expected PASS; the actual fix-list item 3 regression guard. +// --------------------------------------------------------------------------- +globalThis.__bisectLog = []; +const DelegateBisectProbe = defineNativeComponent({ + name: 'NSM1DelegateBisectProbe', + events: ['onBisectResult', 'onHookError'], + create(ctx) { + 'worklet'; + const results = {}; + // A: createDelegate, NO per-instance closure. + try { + const g = globalThis; + const DelClassA = NativeScript.getClass('NSObject'); + const delegateA = DelClassA.extend( + { + scrollViewDidScroll(scrollViewArg) { + g.__bisectLog.push('A-fired'); + }, + }, + {protocols: [NativeScript.getProtocol('UIScrollViewDelegate')]}, + ); + const instA = delegateA.alloc().init(); + results.A = 'ok:' + typeof instA; + } catch (e) { + results.A = 'FAIL:' + String(e) + ' | stack=' + (e && e.stack); + } + + // B: raw NSObject.extend, WITH per-instance closure (captures ctx). + try { + const DelClassB = NativeScript.getClass('NSObject'); + const delegateB = DelClassB.extend( + { + scrollViewDidScroll(scrollViewArg) { + ctx.instance.bFired = true; + }, + }, + {protocols: [NativeScript.getProtocol('UIScrollViewDelegate')]}, + ); + const instB = delegateB.alloc().init(); + results.B = 'ok:' + typeof instB; + } catch (e) { + results.B = 'FAIL:' + String(e) + ' | stack=' + (e && e.stack); + } + + // C: ctx.createDelegate, WITH per-instance closure (captures ctx) -- + // the exact shape the worked example (ARCHITECTURE.md §6) uses. + try { + const delegateC = ctx.createDelegate('UIScrollViewDelegate', { + scrollViewDidScroll(scrollViewArg) { + ctx.instance.cFired = true; + }, + }); + results.C = 'ok:' + typeof delegateC; + } catch (e) { + results.C = 'FAIL:' + String(e) + ' | stack=' + (e && e.stack); + } + + ctx.emit('onBisectResult', results); + return undefined; + }, +}); + +// --------------------------------------------------------------------------- +// ContentSizeProbe: ctx.setContentSize (the Fabric State write-back). +// No explicit style width/height; if the write-back actually feeds Yoga +// sizing (RNS's updateBounds pattern), onLayout should observe ~77x55. +// This run reports the observation; it does not assume the answer. +// --------------------------------------------------------------------------- +const ContentSizeProbe = defineNativeComponent({ + name: 'NSM1ContentSizeProbe', + events: ['onHookError'], + create(ctx) { + 'worklet'; + try { + const g = globalThis; + const view = g.UIView.alloc().init(); + ctx.instance.setContentSizeMainThread = NativeScript.isMainThread(); + ctx.setContentSize({width: 77, height: 55}, {authority: true}); + return view; + } catch (e) { + ctx.emit('onHookError', {component: 'ContentSizeProbe', hook: 'create', message: String(e)}); + return undefined; + } + }, +}); + +// --------------------------------------------------------------------------- +// InvalidateProbe: shouldBeRecycled: false; must be torn down through +// -invalidate, never -prepareForRecycle (M1 review §2/(c), fix-list item 3). +// \`prepareForRecycle\`'s dispose hook fires identically from either path; +// \`viaInvalidate\` is how a spec (and this test) tells them apart. +// --------------------------------------------------------------------------- +const InvalidateProbe = defineNativeComponent({ + name: 'NSM1InvalidateProbe', + events: ['onDisposed'], + shouldBeRecycled: false, + create(ctx) { + 'worklet'; + ctx.instance.created = true; + }, + prepareForRecycle(ctx, viaInvalidate) { + 'worklet'; + ctx.emit('onDisposed', {viaInvalidate: viaInvalidate, mainThread: NativeScript.isMainThread()}); + }, +}); + +export default function App() { + const [phase, setPhase] = useState('detecting'); + const [showChild, setShowChild] = useState(true); + const [tint, setTint] = useState('red'); + const [result, setResult] = useState('Running NativeScript M1 verification...'); + + const readyEvents = useRef([]); + const finalizeEvents = useRef([]); + const pingEvents = useRef([]); + const declineEvents = useRef([]); + const childCountEvents = useRef([]); + const transactionEvents = useRef([]); + const delegateResult = useRef(null); + const bisectResult = useRef(null); + const invalidateResult = useRef(null); + const contentSizeLayouts = useRef([]); + const hookErrors = useRef([]); + const probeRef = useRef(null); + const ran = useRef(false); + + useEffect(() => { + if (ran.current) { + return; + } + ran.current = true; + + (async () => { + try { + const installed = NativeScript.init(); + if (!installed) { + throw new Error('NativeScript Native API JSI host object was not installed'); + } + + // JOB2 (dev-reload / generation invalidation): the closest scriptable + // equivalent to a Metro fast-refresh is a full DevSettings.reload() -- + // it drives the SAME RCTInvalidating.invalidate -> reinstall path + // ARCHITECTURE.md Sec3.5 describes (destroys the UI Hermes VM, bumps + // the gateway's generation counter on reinstall). Phase 1 runs the + // full hook suite, records a non-terminal stage marker, then reloads. + // Phase 2 (detected by reading that marker back after the JS VM has + // been fully torn down and recreated) re-runs the identical suite + // fresh and must pass identically; proving specs re-materialize on + // the new generation with no stale worklet spec and no crash. + // Dedicated phase marker file (__readReloadPhaseMarker), NOT the + // smoke-marker file (__readTestMarker); native's own + // "stage=engine:installed"-style install-sequence writes to the + // smoke marker on every reload clobber it before this code ever + // runs, which caused an infinite reload loop when this used + // __readTestMarker (confirmed on-sim; see NativeScriptNativeApiModule.h). + const priorPhaseMarker = NativeScriptNativeApi.__readReloadPhaseMarker(); + const isPhase2 = priorPhaseMarker.indexOf(PHASE1_STAGE_PREFIX) === 0; + setPhase(isPhase2 ? 'phase2-post-reload' : 'phase1'); + + // create + onReady event round trip + await delay(700); + if (readyEvents.current.length === 0) { + throw new Error('onReady never fired (create -> ctx.emit round trip failed)'); + } + + // updateProps (also arms ctx.scheduleOnMainQueue via Probe) + setTint('green'); + await delay(500); + + // handleCommand: dispatch a real Fabric command from JS to the + // component through the SHIPPED author-facing dispatcher (fix-list + // item 6/§3/#7; not the raw FabricUIManager calls it wraps). + dispatchNativeComponentCommand(probeRef.current, 'ping', [42, 'hello']); + await delay(400); + + // child unmount (mountChildComponentView already exercised by the + // initial render above; this exercises unmountChildComponentView and + // a second mountingTransactionWillMount/DidMount + scheduleOnMainQueue round) + setShowChild(false); + await delay(500); + + const didMountEvents = transactionEvents.current.filter(e => e.phase === 'didMount'); + const willMountEvents = transactionEvents.current.filter(e => e.phase === 'willMount'); + const scheduledEvents = transactionEvents.current.filter(e => e.phase === 'scheduledOnMainQueue'); + + const summary = { + phase: isPhase2 ? 'phase2-post-reload' : 'phase1', + onReady: { + count: readyEvents.current.length, + allMainThread: readyEvents.current.length > 0 && readyEvents.current.every(e => e.mainThread === true), + }, + finalizeUpdates: { + count: finalizeEvents.current.length, + allMainThread: finalizeEvents.current.length > 0 && finalizeEvents.current.every(e => e.mainThread === true), + }, + handleCommand: { + count: pingEvents.current.length, + allMainThread: pingEvents.current.length > 0 && pingEvents.current.every(e => e.mainThread === true), + lastArgs: pingEvents.current.length > 0 ? pingEvents.current[pingEvents.current.length - 1].args : null, + }, + updateLayoutMetricsDecline: { + count: declineEvents.current.length, + allMainThread: declineEvents.current.length > 0 && declineEvents.current.every(e => e.mainThread === true), + frameStayedFixed: + declineEvents.current.length > 0 && + declineEvents.current.every(e => e.actualWidth === 42 && e.actualHeight === 33), + proposedDifferedFromActual: + declineEvents.current.length > 0 && + declineEvents.current.some(e => e.proposedWidth !== e.actualWidth || e.proposedHeight !== e.actualHeight), + }, + mountChildUnmount: { + childCountEvents: childCountEvents.current, + // 4 persistent Stack children (DelegateProbe/ContentSizeProbe/ + // DeclineProbe/Probe) mount first (count reaches 4), then Probe + // unmounts (count drops below the peak); not the M0-era + // single-child [1]->[0] shape. + mountedThenUnmounted: + childCountEvents.current.length > 1 && + Math.max(...childCountEvents.current) === 4 && + childCountEvents.current[childCountEvents.current.length - 1] < Math.max(...childCountEvents.current), + }, + mountingTransaction: { + willMountCount: willMountEvents.length, + didMountCount: didMountEvents.length, + allMainThread: transactionEvents.current.length > 0 && transactionEvents.current.every(e => e.mainThread === true), + instanceForViewAllMatch: didMountEvents.length > 0 && didMountEvents.every(e => e.instanceForViewMatch === true), + }, + scheduleOnMainQueue: { + count: scheduledEvents.length, + allMainThread: scheduledEvents.length > 0 && scheduledEvents.every(e => e.mainThread === true), + }, + createDelegateReentrancy: delegateResult.current, + delegateBisect: bisectResult.current, + invalidate: invalidateResult.current, + contentSize: { + observedLayouts: contentSizeLayouts.current, + observedTargetSize: contentSizeLayouts.current.some( + l => Math.round(l.width) === 77 && Math.round(l.height) === 55, + ), + }, + hookErrors: hookErrors.current, + }; + + const reentrancyOk = + summary.createDelegateReentrancy !== null && + summary.createDelegateReentrancy.maxDepth >= 3 && + summary.createDelegateReentrancy.mainThreadFlags.length >= 3 && + summary.createDelegateReentrancy.mainThreadFlags.every(Boolean); + + // KNOWN GAP (real finding, documented in the report, NOT gated into + // allPass; see JOB1/JOB3 write-up): ctx.createDelegate(), called + // from inside a defineNativeComponent worklet hook with a methods + // object whose functions close over per-instance data (ctx), fails + // synchronously during construction (not method invocation) with + // "[Worklets] Tried to synchronously call a Remote Function." This + // reproduces identically whether the nested method carries its own + // 'worklet' directive or not, and whether invocation is triggered + // synchronously nested in create() or deferred via + // scheduleOnMainQueue; isolated via a checkpoint marker to fire + // at the ctx.createDelegate(...) call itself, before any delegate + // method ever runs. This is precisely the depth/shape that breaks + // JOB3 asked to find and document: depth 0 (construction), not a + // deep-nesting limit. + const knownDelegateGap = summary.hookErrors.filter( + e => e.component === 'DelegateProbe' && String(e.hook).indexOf('create') === 0, + ); + const unexpectedHookErrors = summary.hookErrors.filter( + e => !(e.component === 'DelegateProbe' && String(e.hook).indexOf('create') === 0), + ); + + // M1.5 fixes (see the report): ctx.createDelegate's ACTUAL root + // cause (non-'worklet' functions reachable from a worklet's + // closure) is fixed, so reentrancy is now a hard requirement, not + // just reported; ctx.setContentSize now has both an \`adopt()\` + // consumer AND a fix for a second, independently-discovered bug + // (the state write was silently dropped when called from \`create()\` + // before -updateState: ever fired); observedTargetSize is now + // gated too. + // + // summary.invalidate is deliberately NOT gated here (and expected + // to stay null): confirmed on-sim that Fabric's EventEmitter + // silently no-ops a \`ctx.emit\` made from INSIDE + // -invalidate/-prepareForRecycle even with a live, non-null + // \`_eventEmitter\`; the shadow node has already detached by then + // (upstream RNS never emits from this exact lifecycle point + // either). The REAL proof that \`shouldBeRecycled: false\` reaches + // -invalidate (never -prepareForRecycle), with the dispose hook + // still firing (nsCreated=1), is the NSLog + \`log show\` assertion + // this script's bash driver runs after the marker below. + const allPass = + unexpectedHookErrors.length === 0 && + summary.onReady.count > 0 && summary.onReady.allMainThread && + summary.finalizeUpdates.allMainThread && + summary.handleCommand.count > 0 && summary.handleCommand.allMainThread && + summary.updateLayoutMetricsDecline.allMainThread && + summary.updateLayoutMetricsDecline.frameStayedFixed && + summary.updateLayoutMetricsDecline.proposedDifferedFromActual && + summary.mountChildUnmount.mountedThenUnmounted && + summary.mountingTransaction.willMountCount > 0 && + summary.mountingTransaction.didMountCount > 0 && + summary.mountingTransaction.allMainThread && + summary.mountingTransaction.instanceForViewAllMatch && + summary.scheduleOnMainQueue.count > 0 && + summary.scheduleOnMainQueue.allMainThread && + reentrancyOk && + summary.contentSize.observedTargetSize; + summary.reentrancyOk = reentrancyOk; + summary.knownDelegateGap = knownDelegateGap; + summary.unexpectedHookErrors = unexpectedHookErrors; + + // DevSettings.reload() is a no-op stub when __DEV__ is false (RN's + // own DevSettings.js ships an empty no-op reload() for Release -- + // dev-reload only exists in dev builds). Only attempt the JOB2 half + // of this run in a dev/debug build; a Release run stays single-phase + // (still covers every hook; JOB1; on its own). + const canReload = typeof __DEV__ !== 'undefined' && __DEV__ === true; + + if (!isPhase2 && canReload) { + const stagePayload = PHASE1_STAGE_PREFIX + JSON.stringify(summary) + ' allPass=' + String(allPass); + console.log(stagePayload); + // Dedicated phase file (read back post-reload) + the bash-visible + // "stage=" progress marker on the shared smoke-marker file (for + // the harness's own log, not used for phase detection). + NativeScriptNativeApi.__writeReloadPhaseMarker(stagePayload); + NativeScriptNativeApi.__writeTestMarker(stagePayload); + setResult('Phase 1 done (allPass=' + String(allPass) + '), reloading for JOB2...'); + if (!allPass) { + throw new Error('M1 phase-1 assertion failure: ' + JSON.stringify(summary)); + } + await delay(400); + require('react-native').DevSettings.reload('NativeScript M1 JOB2 dev-reload verification'); + return; + } + + summary.reloadCycleTested = isPhase2; + summary.devReloadAvailable = canReload; + const payload = (allPass ? marker : 'M1_TEST_FAIL') + ' ' + JSON.stringify(summary); + console.log(payload); + NativeScriptNativeApi.__writeTestMarker(payload); + setResult(payload); + if (!allPass) { + throw new Error('M1 verification assertion failure: ' + JSON.stringify(summary)); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error('M1_TEST_FAIL', message); + NativeScriptNativeApi.__writeTestMarker('M1_TEST_FAIL ' + message); + setResult('M1_TEST_FAIL ' + message); + } + })(); + }, []); + + return ( + + { + bisectResult.current = e.nativeEvent; + }} + onHookError={e => { + hookErrors.current.push(e.nativeEvent); + }} + /> + {showChild ? ( + { + invalidateResult.current = e.nativeEvent; + }} + /> + ) : null} + { + childCountEvents.current.push(e.nativeEvent.count); + }} + onTransaction={e => { + transactionEvents.current.push(e.nativeEvent); + }} + onHookError={e => { + hookErrors.current.push(e.nativeEvent); + }}> + { + delegateResult.current = e.nativeEvent; + }} + onHookError={e => { + hookErrors.current.push(e.nativeEvent); + }} + /> + { + contentSizeLayouts.current.push(e.nativeEvent.layout); + }} + onHookError={e => { + hookErrors.current.push(e.nativeEvent); + }} + /> + { + declineEvents.current.push(e.nativeEvent); + }} + onHookError={e => { + hookErrors.current.push(e.nativeEvent); + }} + /> + {showChild ? ( + { + readyEvents.current.push(e.nativeEvent); + }} + onFinalize={e => { + finalizeEvents.current.push(e.nativeEvent); + }} + onPing={e => { + pingEvents.current.push(e.nativeEvent); + }} + /> + ) : null} + + {phase + ': ' + result} + + ); +} +`); +NODE + +rn_install_pods "$APP_DIR" "M1 test app" +UDID=$(rn_require_ios_simulator) +rn_build_ios_app "$APP_DIR" "$APP_ROOT" "$APP_NAME" "$CONFIGURATION" "$UDID" "$BUILD_TIMEOUT_SECONDS" "M1 test app" +APP_BUNDLE="$RN_APP_BUNDLE" + +checkpoint "Launching M1 test app and waiting for the test marker..." +MARKER_FILE=$(rn_launch_app_with_marker "$UDID" "$APP_BUNDLE" "$BUNDLE_ID" "$MARKER_FILE_NAME") +rn_wait_for_marker_file "$MARKER_FILE" "$MARKER" "$LAUNCH_TIMEOUT_SECONDS" + +# M1 review §2/(c), fix-list item 3 verification: ctx.emit cannot prove which +# teardown path a component went through (Fabric silently no-ops events +# dispatched from inside -invalidate/-prepareForRecycle; see the JS-side +# comment above summary.invalidate). NativeScriptComponentView.mm's +# -invalidate/-prepareForRecycle each NSLog their own name (Debug builds +# only); assert directly against the unified log that NSM1InvalidateProbe +# (shouldBeRecycled: false) went through -invalidate and NEVER +# -prepareForRecycle, and NSM1Probe (default) the reverse. +checkpoint "Verifying shouldBeRecycled:false routes through -invalidate (log show)..." +LOG_OUTPUT=$(xcrun simctl spawn "$UDID" log show --last 5m \ + --predicate 'eventMessage CONTAINS "NativeScriptComponentView"' --style compact 2>/dev/null || true) +if ! echo "$LOG_OUTPUT" | grep -q '\[NSM1InvalidateProbe\] -invalidate nsCreated=1'; then + echo "$LOG_OUTPUT" + echo "FAIL: expected an -invalidate log line for NSM1InvalidateProbe (shouldBeRecycled: false) with nsCreated=1." >&2 + exit 1 +fi +if echo "$LOG_OUTPUT" | grep -q '\[NSM1InvalidateProbe\] -prepareForRecycle'; then + echo "$LOG_OUTPUT" + echo "FAIL: NSM1InvalidateProbe (shouldBeRecycled: false) went through -prepareForRecycle. Expected -invalidate." >&2 + exit 1 +fi +if ! echo "$LOG_OUTPUT" | grep -q '\[NSM1Probe\] -prepareForRecycle nsCreated=1'; then + echo "$LOG_OUTPUT" + echo "FAIL: expected an -prepareForRecycle log line for NSM1Probe (default shouldBeRecycled) with nsCreated=1." >&2 + exit 1 +fi +checkpoint "shouldBeRecycled:false / -invalidate routing verified." + +checkpoint "NativeScript React Native TurboModule M1 acceptance test passed."