diff --git a/packages/react-native/README.md b/packages/react-native/README.md index 4a38a7a1..c560ca27 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -1,25 +1,171 @@ # @nativescript/react-native -React Native TurboModule wrapper for the NativeScript Native API JSI bridge on -Hermes. +NativeScript's Native API for React Native: call Objective-C and UIKit on iOS, +and Java and the Android SDK on Android, directly from JavaScript. -The module exposes one small TurboModule whose `init()` method attaches the +The package is one small TurboModule. Its `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. +installs lazy NativeScript-style globals for native classes, functions, +constants and enums. The host object itself is pure JSI and is shared with the +NativeScript runtime. + +## Requirements + +- React Native 0.79 or newer. +- iOS 13+, with Hermes and the New Architecture. +- `react-native-worklets` 0.8 or newer. It is required on iOS: the pod links + `RNWorklets`, and UIKit work runs on the Worklets UI runtime. +- Android works with Hermes or JSC. See [Android](#android). + +Expo Go cannot load this package because it contains custom native code. Use an +Expo development build, EAS Build, or `npx expo run:ios` / `npx expo run:android`. + +## Installation + +### React Native + +1. Install the package and Worklets: + + ```sh + npm install @nativescript/react-native react-native-worklets + ``` + +2. Configure the project: + + ```sh + npx nativescript-rn configure + ``` + + `configure` adds the NativeScript and Worklets Babel plugins when missing, + writes `nativescript.react-native.json`, applies the Android Gradle script, + adds the Android typings to `tsconfig.json`, and warns when iOS is not + configured for Hermes and the New Architecture. It is intentionally + conservative and does not make destructive native project edits. + + To configure Babel by hand instead: + + ```js + module.exports = { + presets: ["module:@react-native/babel-preset"], + plugins: [ + "@nativescript/react-native/babel-plugin", + ["react-native-worklets/plugin", { strictGlobal: true }], + ], + }; + ``` + + `strictGlobal: true` is required. Without it the Worklets plugin captures + every name a worklet does not declare (`UIView`, `UILabel`, ...) into the + worklet's closure, read on the JavaScript thread when the module defining + the worklet is evaluated. NativeScript's classes do not exist there, so the + app fails at startup with `ReferenceError: Property 'UILabel' doesn't exist`. + With `strictGlobal`, those names are resolved on the runtime the worklet runs + on. With `babel-preset-expo`, also pass `{ worklets: false }` to the preset + so it does not add a second copy of the plugin without `strictGlobal`. + +3. Install the pods: + + ```sh + cd ios + RCT_NEW_ARCH_ENABLED=1 USE_HERMES=1 pod install + ``` + +`npx nativescript-rn generate-metadata --check` prints the iOS metadata +configuration (extra pods and system frameworks) that native builds will use. + +### Expo + +1. Install the package and Worklets: + + ```sh + npx expo install @nativescript/react-native react-native-worklets + ``` + +2. Add the config plugin to `app.json` or `app.config.js`: + + ```json + { + "expo": { + "plugins": ["@nativescript/react-native"] + } + } + ``` + + The plugin: + + - configures iOS for Hermes and the React Native New Architecture, which this + JSI TurboModule requires; + - applies the NativeScript Gradle script to `android/app/build.gradle`; + - adds the `@nativescript/react-native/babel-plugin` and + `react-native-worklets/plugin` (with `{ strictGlobal: true }`) transforms to + `babel.config.js`, and passes `{ worklets: false }` to `babel-preset-expo` + so the preset does not add its own copy of the Worklets plugin. An existing + `react-native-worklets/plugin` entry that already has options is left + alone, with a warning if it lacks `strictGlobal: true`. + + Set `{ "babelPlugin": false }` in the plugin options if you prefer to add the + NativeScript and Worklets Babel plugins manually. + +3. Prebuild and run a development build: + + ```sh + npx expo prebuild + npx expo run:ios + npx expo run:android + ``` + +The plugin also writes `nativescript.react-native.json` so metadata options are +visible to native builds. On iOS you can pass metadata inputs when the app uses +Objective-C-visible pods or extra system frameworks: + +```json +{ + "expo": { + "plugins": [ + [ + "@nativescript/react-native", + { + "metadata": { + "includePods": ["SomeObjCSDK"], + "includeSystemFrameworks": ["UIKit", "MapKit", "WebKit"] + } + } + ] + ] + } +} +``` + +For Android release builds, `metadata.android.include` lists classes to keep; +see [Android setup](#setup). Bare projects using `nativescript-rn configure` can +put the same `metadata` object under a `nativeScriptReactNative` key in +`package.json`. An `android` block written into `nativescript.react-native.json` +by hand is kept unless the options set one. + +Importing the package in a build without its native module (Expo Go, or a build +made before the package was added) throws an error that says to use a +development build or rebuild the app. + +## Usage + +Call `init()` once, before using native APIs: ```ts import NativeScript from "@nativescript/react-native"; NativeScript.init(); - -const object = NSObject.new(); ``` -`NativeScript.init()` also installs the Native API into the -`react-native-worklets` UI runtime. `NativeScript.runOnUI()` only accepts -Worklets callbacks; running React Native's JS-thread runtime as a UI-thread shim -is not supported. +### Which runtime gets the globals (iOS) + +UIKit must only be touched on the main thread, and React Native's JavaScript +runs on its own JS thread. So on iOS, `init()` installs the full set of native +globals (`UIView`, `NSObject`, `NSTextAlignment`, ...) into the +`react-native-worklets` UI runtime, which runs on the main thread. On the +JavaScript thread it installs only `globalThis.__nativeScriptNativeApi`, +`interop`, and protocol globals. + +Run UIKit work through `NativeScript.runOnUI()` with a Worklets callback: ```ts await NativeScript.runOnUI(() => { @@ -28,26 +174,40 @@ await NativeScript.runOnUI(() => { }); ``` -Install `react-native-worklets`, add its Babel plugin, and run `pod install` so -the `RNWorklets` pod is linked: +`runOnUI()` only accepts Worklets callbacks and throws when the callback was not +transformed into a Worklets function; running React Native's JS-thread runtime +as a UI-thread shim is not supported. `installWorklets()` is still exported for +custom initialization, but it throws when Worklets is unavailable or +incompatible. -```sh -npm install react-native-worklets -``` +Native objects the worklet returns, at the top level or inside plain arrays and +objects, come back as objects of the React Native runtime. Each runtime has its +own wrapper for a native object, and using a wrapper from the other runtime +throws. To hand one across yourself, pass `interop.handleof(object).toHexString()` +and turn it back with `interop.object(handle)`. -```js -module.exports = { - presets: ["module:@react-native/babel-preset"], - plugins: [ - "@nativescript/react-native/babel-plugin", - "react-native-worklets/plugin", - ], -}; +For non-UI APIs on the JavaScript thread (Foundation, file system, networking), +either look classes up explicitly or opt into JS-thread globals: + +```ts +const NSDateClass = NativeScript.getClass("NSDate"); + +// or +NativeScript.init("", { globals: true }); +const now = NSDate.new(); ``` -`installWorklets()` is still exported for custom initialization, but it throws -when Worklets is unavailable or incompatible. `runOnUI()` throws when the -callback was not transformed into a Worklets function. +Only full C constant names become globals, for example `NSTextAlignmentCenter` +and `NSOrderedAscending`; short enum member names stay on their enum +(`NSTextAlignment.Center`). JS-thread globals make it possible to touch UIKit +off the main thread by accident, which is why they are opt-in. Helpers that +build native classes, such as `createDelegate()`, need them when called on the +JavaScript thread. + +On Android, `init()` installs the Java package namespaces (`java`, `android`, +...) on the JavaScript runtime. See [Android](#android). + +### Callbacks and threads Obj-C blocks and JS-backed Obj-C method callbacks, including `NSObject.extend` subclass overrides and delegates created with `createDelegate()`, should return @@ -55,13 +215,17 @@ to React Native's JS thread for JS work. Use `jsInvoker()` when a callback can b reached from a native caller thread: ```ts -UIView.animateWithDurationAnimationsCompletion( - 0.25, - null, - NativeScript.jsInvoker((finished) => { - console.log("animation finished", finished); - }), -); +NativeScript.init("", { globals: true }); + +const url = NSURL.URLWithString("https://nativescript.org"); +NSURLSession.sharedSession + .dataTaskWithURLCompletionHandler( + url, + NativeScript.jsInvoker((data, response, error) => { + console.log("request finished", error == null); + }), + ) + .resume(); ``` Delegate, data-source, target/action, and `UIAction` callbacks are JS-side @@ -69,7 +233,7 @@ callbacks. Treat their bodies as JS work. If a callback can be reached from a background native thread and needs to mutate UIKit, wrap the mutation in `NativeScript.runOnUI()` with a Worklets callback. -The package also includes a Babel plugin for directive-style JS callbacks: +The bundled Babel plugin also supports directive-style JS callbacks: ```ts someNativeApi(() => { @@ -145,11 +309,11 @@ badgeRef.current?.invalidateNativeLayout(); 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. +UIKit definition; add a `nativeProps(props)` function to the definition 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 @@ -239,7 +403,9 @@ retainer.dispose(); `createDelegate(protocols, methods, options)` accepts protocol objects or names. If metadata was generated before a framework was loaded, use strings with -`NativeScript.loadFramework()` and `NativeScript.getProtocol()`: +`NativeScript.loadFramework()` and `NativeScript.getProtocol()`. Passing +`{ owner: ctx }` from a definition's `create(ctx)` ties the delegate to the +component's lifetime: ```ts NativeScript.loadFramework("QuickLook"); @@ -251,7 +417,7 @@ const dataSource = NativeScript.createDelegate( return 1; }, previewControllerPreviewItemAtIndex() { - return NSURL.fileURLWithPath(path); + return NSURL.fileURLWithPath(ctx.props.path); }, }, { owner: ctx }, @@ -397,12 +563,13 @@ export const NativeTabs = NativeScript.defineUIViewController< ``` For modal UIKit controllers, find the top visible presenter and guard against -double presentation: +double presentation. Helpers called from a worklet must be worklets too: ```ts function topVisibleViewController( root = UIApplication.sharedApplication.keyWindow?.rootViewController, ) { + "worklet"; let current = root; while (current?.presentedViewController) { current = current.presentedViewController; @@ -440,7 +607,6 @@ if ( const CameraController = NativeScript.getClass< typeof VNDocumentCameraViewController >("VNDocumentCameraViewController"); - const controller = CameraController?.new(); } ``` @@ -461,168 +627,11 @@ 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 published package includes generated NativeScript metadata, the libffi -xcframework, and generated iOS SDK TypeScript declarations. Build it from the -repository root with: - -```sh -npm run build-rn-turbomodule -``` - -The tarball is written to `packages/react-native/dist/` and copied to -`build/npm-tarballs/`. - -To verify it inside a generated React Native iOS app: - -```sh -npm run test-rn-turbomodule -``` - -## Using the package in a React Native app +More example definitions ship in the package's `examples/` folder, for example +`@nativescript/react-native/examples/UIKitSwitch`. -1. Build or download the package tarball. -2. Install it in an RN app that has Hermes and the New Architecture enabled: - - ```sh - npm install /path/to/nativescript-react-native-0.0.1.tgz react-native-worklets - cd ios - RCT_NEW_ARCH_ENABLED=1 USE_HERMES=1 pod install - ``` - -3. Initialize it before using native APIs: - - ```ts - import NativeScript from "@nativescript/react-native"; - - NativeScript.init(); - - await NativeScript.runOnUI(() => { - "worklet"; - UIApplication.sharedApplication.keyWindow.tintColor = - UIColor.systemPinkColor; - }); - ``` - -4. Add the bundled NativeScript Babel plugin and the Worklets Babel plugin: - - ```js - module.exports = { - presets: ["module:@react-native/babel-preset"], - plugins: [ - "@nativescript/react-native/babel-plugin", - "react-native-worklets/plugin", - ], - }; - ``` - -## Using the package in an Expo app - -Expo Go cannot load this package because it contains custom native code. Use an -Expo development build, EAS Build, or `npx expo run:ios`. - -1. Install the package: - - ```sh - npx expo install @nativescript/react-native react-native-worklets - ``` - - When testing a local tarball: - - ```sh - npm install /path/to/nativescript-react-native-0.0.1.tgz - ``` - -2. Add the config plugin to `app.json` or `app.config.js`: - - ```json - { - "expo": { - "plugins": ["@nativescript/react-native"] - } - } - ``` - - The plugin configures iOS for Hermes and the React Native New Architecture, - which are required by this JSI TurboModule. It also adds the - `@nativescript/react-native/babel-plugin` and `react-native-worklets/plugin` - transforms to `babel.config.js` so `"use js"` and worklet callbacks work in - Expo bundles. - -3. Prebuild and run the iOS development build: - - ```sh - npx expo prebuild --platform ios - npx expo run:ios - ``` - -4. Initialize NativeScript in app code before using native APIs: - - ```tsx - import NativeScript, { defineUIKitView } from "@nativescript/react-native"; - - NativeScript.init(); - - const NativeBadge = defineUIKitView<{ title: string }, UIView>({ - name: "NativeBadge", - create() { - const view = UIView.alloc().initWithFrame(CGRectZero); - const label = UILabel.alloc().initWithFrame(CGRectZero); - label.tag = 1; - label.textAlignment = NSTextAlignment.Center; - view.addSubview(label); - return view; - }, - update(view, props) { - view.backgroundColor = UIColor.systemBlueColor; - const label = view.viewWithTag(1) as UILabel; - label.text = props.title; - }, - }); - ``` - -Set `{ "babelPlugin": false }` in the config plugin options if you prefer to add -the NativeScript and Worklets Babel plugins manually. - -The plugin also writes `nativescript.react-native.json` so metadata options are -visible to native builds. You can pass metadata inputs when the app uses -Objective-C-visible pods or extra system frameworks: - -```json -{ - "expo": { - "plugins": [ - [ - "@nativescript/react-native", - { - "metadata": { - "includePods": ["SomeObjCSDK"], - "includeSystemFrameworks": ["UIKit", "MapKit", "WebKit"] - } - } - ] - ] - } -} -``` - -## Bare React Native setup helper - -The tarball includes a small CLI for bare RN projects: - -```sh -npx nativescript-rn configure -npx nativescript-rn generate-metadata --check -cd ios -RCT_NEW_ARCH_ENABLED=1 USE_HERMES=1 pod install -``` - -`configure` adds the bundled NativeScript and Worklets Babel plugins when -missing, writes -`nativescript.react-native.json`, and warns when the app is not configured for -Hermes and the New Architecture. The command is intentionally conservative and -does not make destructive native project edits. +The package includes generated NativeScript metadata, the libffi xcframework, +and generated iOS SDK TypeScript declarations. ## Android @@ -631,14 +640,24 @@ Android gives you Java and the Android SDK — plus every library your app depends on, because the metadata is generated from your app's own classpath. ```ts -import NativeScriptNativeApi from "@nativescript/react-native/src/NativeScriptNativeApi"; +import NativeScript from "@nativescript/react-native"; -NativeScriptNativeApi.install(""); +NativeScript.init(); const now = java.lang.System.currentTimeMillis(); const sdk = android.os.Build.VERSION.SDK_INT; ``` +Each top-level Java package is a global named after it (`java`, `android`, +`androidx`, `com`, ...). A package whose name is a JavaScript keyword gets a `$` +prefix instead. So does one whose name is already a global when NativeScript +installs, and a warning is logged. Expo defines `expo`, for example, so Expo's +own Java classes are under `$expo`: + +```ts +const modules = $expo.modules; +``` + ### How it works The runtime does not bring a JavaScript engine. It binds to the @@ -650,8 +669,8 @@ prebuilt binary could not be safe across them. ### Setup -`npx nativescript-rn configure` adds the Gradle script and the typings entries. -To do it by hand, in `android/app/build.gradle`, after the React plugin: +`npx nativescript-rn configure` and the Expo config plugin both add the Gradle +script. To do it by hand, in `android/app/build.gradle`, after the React plugin: ```groovy apply from: "../../node_modules/@nativescript/react-native/android/nativescript.gradle" @@ -659,19 +678,57 @@ apply from: "../../node_modules/@nativescript/react-native/android/nativescript. That adds two things to your build: -* **Metadata** — a description of every class on the app's resolved classpath, +* **Metadata** — a description of the classes on the app's resolved classpath, generated into the APK's assets on every build. This is what makes `android.widget.Toast` and any third-party library resolvable from JavaScript. `whitelist.mdg` / `blacklist.mdg` next to `android/app/build.gradle` filter it, with the same format the NativeScript CLI uses. -* **TypeScript declarations** — off by default because they take a while: + + Debug builds ship the whole classpath. Release builds ship only what the + JavaScript bundle can reach, plus the supertypes, nested classes and + signature types those classes need. They also leave out the packages of + autolinked React Native modules, which are reached over the TurboModule + bridge rather than the interop. A class your code only reaches dynamically + (a name built at runtime, for example) can therefore resolve in debug and be + missing in release. Declare such classes in `nativescript.react-native.json` + at the project root, as `"pkg.Class"`, `"pkg.*"` or `"pkg:Class"`: + + ```json + { + "reactNative": { + "metadata": { + "android": { + "include": ["com.example.sdk.Worker", "com.example.views.*"] + } + } + } + } + ``` + + With Expo, put the same `android` object under `metadata` in the config + plugin options. `-PnsFilterMetadata=false` turns the filter off and ships + the whole classpath. +* **TypeScript declarations** — for the same classpath, so third-party + libraries are typed too. They are written to `types/android/` next to the app + on the first build and regenerated only when the classpath changes; commit + them alongside your code. `-PnsGenerateTypings=false` opts out, and ```bash - cd android && ./gradlew generateNativeScriptTypingsDebug -PnsGenerateTypings=true + cd android && ./gradlew generateNativeScriptTypingsDebug + ``` + + generates them on their own. `configure` adds them to `tsconfig.json`; with + the Expo config plugin, add them yourself: + + ```json + { + "include": ["types/android/android.d.ts", "types/android/android-declarations.d.ts"] + } ``` - which writes `types/android/` and, with `configure` having run, is already - referenced from `tsconfig.json`. + The package does not ship Android declarations, and `@nativescript/types-android` + is not needed: it covers only the Android SDK, and its declarations collide + with the generated ones. ### Extending Java classes @@ -775,6 +832,32 @@ you. The one thing to avoid is blocking the JS thread while waiting for such a callback — `thread.join()` immediately after `thread.start()` deadlocks, because the callback cannot run until the JS thread is free again. +That includes callbacks Android runs on the main thread: a `Runnable` posted to +`new Handler(Looper.getMainLooper())` runs its JavaScript on the JS thread. There +is no Worklets UI runtime on Android yet, so `runOnUI()` and the UIKit view +helpers are iOS only, and touching an `android.view.View` from JavaScript throws +`CalledFromWrongThreadException`. Every non-UI Android API is available. + +### Reloads + +A reload (Fast Refresh's full reload, `DevSettings.reload()`, or +`expo-updates`' `reloadAsync()`) creates a new JavaScript runtime. NativeScript +lets go of the old one and installs its globals into the new one, so +`NativeScript.init()` works the same after a reload. + +Java objects that the old runtime created or extended stay bound to it. If one of +them calls back into JavaScript after the reload, for example a delayed `Runnable` +or a listener that was never removed, the call throws a `NativeScriptException`, +the same as a callback into a terminated worker: + +``` +com.tns.NativeScriptException: Cannot find runtime for instance=com.tns.gen.java.lang.Runnable@708609e +``` + +If this happens on the main thread, the app crashes. To avoid it, remove +listeners and cancel posted callbacks when the component that registered them +unmounts. + ### What a guest does not install React Native already provides `console`, timers, `queueMicrotask`, @@ -782,3 +865,19 @@ React Native already provides `console`, timers, `queueMicrotask`, installs only the metadata globals, the object manager and the interop callbacks. `Worker` is not installed either: a worker needs its own VM, which a guest has no way to create. + +## Building from source + +From the root of the [NativeScript/runtimes](https://github.com/NativeScript/runtimes) +repository: + +```sh +npm run build-rn-turbomodule +``` + +The tarball is written to `packages/react-native/dist/` and copied to +`build/npm-tarballs/`. To verify it inside a generated React Native iOS app: + +```sh +npm run test-rn-turbomodule +```