From 3748bc72074b1f9d178e733e330cf0cf75b8c883 Mon Sep 17 00:00:00 2001 From: Laurids Kern Date: Thu, 28 May 2026 15:46:55 +0200 Subject: [PATCH 1/2] docs: update Nitro module best practices --- skills/build-nitro-modules/SKILL.md | 24 +++-- .../references/api-design-best-practices.md | 93 +++++++++++++++++++ .../references/native-implement-cpp.md | 4 + .../references/native-implement-kotlin.md | 7 +- .../references/native-implement-swift.md | 10 +- .../references/spec-hybrid-object.md | 23 ++++- 6 files changed, 142 insertions(+), 19 deletions(-) create mode 100644 skills/build-nitro-modules/references/api-design-best-practices.md diff --git a/skills/build-nitro-modules/SKILL.md b/skills/build-nitro-modules/SKILL.md index e924046..ba0bcd5 100644 --- a/skills/build-nitro-modules/SKILL.md +++ b/skills/build-nitro-modules/SKILL.md @@ -83,16 +83,17 @@ Reference these guidelines when: | Priority | Category | Impact | Reference | |----------|----------|--------|-----------| | 1 | Monorepo scaffold | CRITICAL | [setup-monorepo-init.md][setup-monorepo-init] | -| 2 | HybridObject spec | CRITICAL | [spec-hybrid-object.md][spec-hybrid-object] | -| 3 | nitro.json autolinking | CRITICAL | [spec-nitro-json.md][spec-nitro-json] | -| 4 | Nitrogen codegen | HIGH | [native-nitrogen-codegen.md][native-nitrogen-codegen] | -| 5 | C++ implementation | HIGH | [native-implement-cpp.md][native-implement-cpp] | -| 6 | Kotlin implementation | HIGH | [native-implement-kotlin.md][native-implement-kotlin] | -| 7 | Swift implementation | HIGH | [native-implement-swift.md][native-implement-swift] | -| 8 | Example app setup *(if requested)* | HIGH | [example-app-setup.md][example-app-setup] | -| 9 | Android Gradle paths *(if example app)* | HIGH | [example-android-config.md][example-android-config] | -| 10 | Metro + install + test *(if example app)* | HIGH | [example-metro-install.md][example-metro-install] | -| 11 | npm publish prep | MEDIUM | [spec-package-publish.md][spec-package-publish] | +| 2 | API design best practices | CRITICAL | [api-design-best-practices.md][api-design-best-practices] | +| 3 | HybridObject spec | CRITICAL | [spec-hybrid-object.md][spec-hybrid-object] | +| 4 | nitro.json autolinking | CRITICAL | [spec-nitro-json.md][spec-nitro-json] | +| 5 | Nitrogen codegen | HIGH | [native-nitrogen-codegen.md][native-nitrogen-codegen] | +| 6 | C++ implementation | HIGH | [native-implement-cpp.md][native-implement-cpp] | +| 7 | Kotlin implementation | HIGH | [native-implement-kotlin.md][native-implement-kotlin] | +| 8 | Swift implementation | HIGH | [native-implement-swift.md][native-implement-swift] | +| 9 | Example app setup *(if requested)* | HIGH | [example-app-setup.md][example-app-setup] | +| 10 | Android Gradle paths *(if example app)* | HIGH | [example-android-config.md][example-android-config] | +| 11 | Metro + install + test *(if example app)* | HIGH | [example-metro-install.md][example-metro-install] | +| 12 | npm publish prep | MEDIUM | [spec-package-publish.md][spec-package-publish] | ## Quick Reference @@ -144,6 +145,7 @@ Run: `bun example android`, `bun example ios`, `bun specs` | File | Description | |------|-------------| | [setup-monorepo-init.md][setup-monorepo-init] | Monorepo workspace structure and `nitrogen init` scaffold | +| [api-design-best-practices.md][api-design-best-practices] | Nitro API shape, typed specs, errors, native state, memory, buffers, hooks, and Harness tests | | [spec-hybrid-object.md][spec-hybrid-object] | Writing `*.nitro.ts` specs and exporting HybridObjects | | [spec-nitro-json.md][spec-nitro-json] | `nitro.json` all fields, autolinking, namespace configuration | | [native-nitrogen-codegen.md][native-nitrogen-codegen] | Running Nitrogen and verifying generated files | @@ -160,6 +162,7 @@ Run: `bun example android`, `bun example ios`, `bun specs` | Problem | Reference | Action | |---------|-----------|--------| | Don't know where to start | [setup-monorepo-init.md][setup-monorepo-init] | Scaffold with `nitrogen init` | +| API shape unclear | [api-design-best-practices.md][api-design-best-practices] | Prefer typed, instance-based APIs with explicit errors | | Spec file syntax error | [spec-hybrid-object.md][spec-hybrid-object] | Fix `*.nitro.ts` interface | | Autolinking not working | [spec-nitro-json.md][spec-nitro-json] | Check `nitro.json` autolinking block | | Nitrogen generates no files | [native-nitrogen-codegen.md][native-nitrogen-codegen] | Verify spec file extension and run command from right dir | @@ -172,6 +175,7 @@ Run: `bun example android`, `bun example ios`, `bun specs` | Package missing files on npm | [spec-package-publish.md][spec-package-publish] | Fix `files` field in `package.json` | [setup-monorepo-init]: references/setup-monorepo-init.md +[api-design-best-practices]: references/api-design-best-practices.md [spec-hybrid-object]: references/spec-hybrid-object.md [spec-nitro-json]: references/spec-nitro-json.md [native-nitrogen-codegen]: references/native-nitrogen-codegen.md diff --git a/skills/build-nitro-modules/references/api-design-best-practices.md b/skills/build-nitro-modules/references/api-design-best-practices.md new file mode 100644 index 0000000..fbffe41 --- /dev/null +++ b/skills/build-nitro-modules/references/api-design-best-practices.md @@ -0,0 +1,93 @@ +--- +title: Nitro API Design Best Practices +impact: CRITICAL +tags: api-design, best-practices, hybrid-object, types, errors, performance, harness +--- + +# Skill: Nitro API Design Best Practices + +Use this before writing the `.nitro.ts` spec and keep using it while implementing Swift/Kotlin/C++. Nitro benefits most from explicit, typed, instance-based APIs that expose native state safely instead of copying everything through JS. + +## Hard Rules + +- **Do not inherit from `_base`.** Implement the generated spec directly (`HybridFooSpec`) or the real Nitro base type required by the current API. Generated `_base` types are implementation details and should not be part of library code. +- **Don't create functions that could be simple property getters.** For example, use `readonly isAccelerometerAvailable: boolean` instead of `isAccelerometerAvailable(): boolean` when the value is state-like and cheap to read. +- **Use `is*`, `has*`, or similar prefixes for boolean properties.** Bad: `accelerometerAvailable`. Good: `isAccelerometerAvailable`. +- **Use objects / structs instead of 3+ params.** If a method has three or more related arguments, define a typed options/result struct in the `.nitro.ts` spec. +- **Always prefer typed structs/methods/types over `AnyMap` or `Record`.** Nitro benefits from statically typed bindings for better performance. Variants (`A | B`) also require runtime overhead, but those are fine; if they can be avoided it is good, but otherwise its no biggie. +- **Use Nitro's recommended types for TypeScript specs.** Prefer primitives (`number`, `string`, `UInt64`, `boolean`), `ArrayBuffer`, `Promise`, typed structs, and callbacks. Use `Error` instead of custom typed errors, as only those are true JS `Error` prototypes. +- **Do not create multiple types in a single file at top level.** Create multiple files for stuff like this. The only exception is truly local types/structs/classes/enums, which can be nested private. +- **Make almost all classes `final`.** This is better for performance, unless you really expect them to be overridden. Usually that never happens in Nitro HybridObjects. + +## Error Handling + +- **Avoid swallowing errors silently.** Don't just early return, don't just log/print. Either throw or reject promise if possible, or have a separate error listener if an error is being sent in a different execution context. +- Do not write guards like `guard motionManager.isAccelerometerAvailable else { return }`. Throw a Nitro runtime error or reject the promise so JS can handle the failure. +- **Avoid silently swallowing not implemented functions.** If a feature is not available on a platform, throw an explicit not-available/not-implemented error. +- Do not use `NSError` or Objective-C types in general. Prefer Nitro's runtime error type, for example Swift's `RuntimeError("...")` from `NitroModules`. +- For impossible-to-reach states, you can use static assertions or `fatalError(...)` in Swift, but this should be super rare and not for stuff that can be reached via faulty JS user code. + +```swift +import NitroModules + +final class HybridMotion: HybridMotionSpec { + var isAccelerometerAvailable: Bool { + return motionManager.isAccelerometerAvailable + } + + func startAccelerometerUpdates() throws { + guard motionManager.isAccelerometerAvailable else { + throw RuntimeError("Accelerometer is not available on this device.") + } + motionManager.startAccelerometerUpdates() + } +} +``` + +## Native State and Object Shape + +- **Nitro allows us to use native state (`HybridObject`).** This is especially useful to zero-copy bridge native data, for example large `UIImage`, zero-copy access via `ArrayBuffer`, or properties/methods on it. +- Use instance-based APIs and native state API design patterns. For haptics modules, instead of making everything a static method as you would have in a TurboModule, make the haptics engine an instance (`HybridObject`) and pre-warm the engine so that a call to trigger can be faster. +- For HybridObjects that require arguments to be constructed, use a factory pattern. For example, for a Nitro `File` object that needs to be created from a path string, create `FileFactory` with a method like `loadFileFromPath(path: string): Promise`. Here, `Promise` is important because it is async. For non-async/fast methods use sync APIs. +- Use sync methods by default as that is super fast, but for anything that takes longer to execute, for example hardware calls or async APIs, use async methods (`Promise<...>`). In native code, use `Promise.parallel` for dispatch queue/thread based work or `Promise.async` for async/coroutine based work depending on the native threading requirements. +- Use descriptive method names. For helpers, prefer names like `timestampMs()` over vague names like `timestamp()`. + +```swift +private static func timestampMs() -> Double { + return Date().timeIntervalSince1970 * 1_000.0 +} +``` + +## Memory, Views, and Buffers + +- For types that hold onto native resources or have large memory allocations, implement `memorySize` (an overridable property in `HybridObject`) and try to estimate the size somewhat closely. For example, estimate a `UIImage` byte size by multiplying width, height, and bytes per pixel. This helps the JS VM / GC delete sooner and avoid memory stress. +- For Nitro Views, implement view recycling if applicable. Use `prepareForRecycle`, which is overridable from `HybridView`. +- For zero-copy data access, use `ArrayBuffer`; it can wrap many native buffer types. +- When receiving an `ArrayBuffer` from JS but you need to use it on a different thread, check if it is owning or not via `arrayBuffer.isOwner`. If not, copy it first: `let buffer = arrayBuffer.isOwner ? arrayBuffer : ArrayBuffer.copy(of: arrayBuffer)`. + +## Platform Interop + +- If you need Android context, use `NitroModules.applicationContext` and just throw if it is not available. +- If you need to mix C++ and platform languages (Swift/Kotlin), you can do so in Nitro. This allows re-using C++ code across iOS and Android. +- You can pass a HybridObject implemented in a platform language (for example Swift/Kotlin) to C++ and use it as normal in C++. This is useful for something like SQLite, where a `PlatformFilesystem` HybridObject has a `createFile(): string` method. JS can create `PlatformFilesystem` and pass it to a C++ SQLite HybridObject, and C++ can call `createFile()` directly even though it is implemented in Swift/Kotlin. +- Prefer Nitro Modules over JSI. Nitro is not only faster than TurboModules and often even faster than handwritten JSI, but also much safer: it avoids retaining JSI values across different threads, calling Promise resolve after runtime destruction, and similar crash-prone patterns. +- Resort to JSI only if absolutely needed via Raw JSI Methods (`prototype.registerRawHybridMethod` API from Nitro C++). + +## TypeScript Layer + +- In React Native environments, try to provide Hooks APIs on top of the imperative APIs. Use an initial getter (`sync` in `useMemo`/`useRef`/`useSyncExternalStore`, async via `useEffect` or similar) plus listener-based APIs with an unsubscribe function via `useEffect`. +- When creating TypeScript abstractions on top of native Nitro bindings, you can use TypeScript features like discriminating unions or nullables with default values more easily. Those things would have slight overhead and more type complexity in native Nitro code. +- Example: `takePhoto(options:)` might have a complex options struct. Making all of that optional causes more complex native code; simply making it non optional and providing default values via TypeScript (`??`) makes it simpler, easier to inline for the JS engine, and lower bridging cost. +- The goal should always be to provide simpler and more explicit native APIs. Don't overengineer this; if it is overcomplicating native code then it is not worth the minor performance gain. +- Use callbacks for asynchronous functions, and in super rare cases when a callback needs to run fully synchronously/blocking on the JS Thread, use `Sync<(...) => ...>`, for example for worklets interop. See `react-native-vision-camera` V5 `FrameOutput.nitro.ts` for an example on how to use `Sync` callbacks on Worklet Threads. + +## Testing + +- If available, use `react-native-harness` for end-to-end testing Nitro modules. +- Write Harness tests for real features to ensure that each individual feature, input/param combination, setting, property, combination, order of execution, and more works properly in a real React Native environment. +- Use Harness CI tests via GitHub Actions to continuously iterate until CI is green and to cover more API surface area / combinations. +- Test behaviour. There is no point in testing types like `toBeDefined`, `Array.isArray`, or `typeof`, because Nitro(gen) already enforces types at compile-time. + +## When Unsure + +Refer to Nitro's docs if anything else is unclear: diff --git a/skills/build-nitro-modules/references/native-implement-cpp.md b/skills/build-nitro-modules/references/native-implement-cpp.md index f9cb193..437e753 100644 --- a/skills/build-nitro-modules/references/native-implement-cpp.md +++ b/skills/build-nitro-modules/references/native-implement-cpp.md @@ -206,9 +206,13 @@ void HybridMath::compute(double input, std::function onResult) { - **Using `float` instead of `double`** — Nitro uses `double` for all `number` types - **Using `std::future`** — Nitro does not use `std::future`; always use `std::shared_ptr>` with `Promise::async(...)` - **Missing `TAG` member** — Required for `HybridObject(TAG)` constructor call +- **Inheriting from generated `_base` types** — Do not inherit from `_base`; implement the generated spec directly or the real Nitro base type required by the current API +- **Silently returning on unavailable APIs** — Throw a clear exception instead of returning, logging, or printing +- **Generic maps by default** — Prefer typed structs/methods/types over `AnyMap` or `Record` ## Related Skills +- [api-design-best-practices.md](api-design-best-practices.md) — API shape, errors, native state, memory, buffers, hooks, and Harness tests - [native-nitrogen-codegen.md](native-nitrogen-codegen.md) — Must generate specs before implementing - [spec-nitro-json.md](spec-nitro-json.md) — Configure `"c++"` in autolinking - [native-implement-kotlin.md](native-implement-kotlin.md) — Android Kotlin alternative diff --git a/skills/build-nitro-modules/references/native-implement-kotlin.md b/skills/build-nitro-modules/references/native-implement-kotlin.md index de08f99..77c6caf 100644 --- a/skills/build-nitro-modules/references/native-implement-kotlin.md +++ b/skills/build-nitro-modules/references/native-implement-kotlin.md @@ -21,7 +21,7 @@ class HybridMath : HybridMathSpec() { ```kotlin @Keep @DoNotStrip -class HybridMath : HybridMathSpec() { +final class HybridMath : HybridMathSpec() { override fun add(a: Double, b: Double): Double = a + b } ``` @@ -65,7 +65,7 @@ import com.facebook.proguard.annotations.DoNotStrip @Keep @DoNotStrip -class HybridMath : HybridMathSpec() { +final class HybridMath : HybridMathSpec() { // Synchronous method override fun add(a: Double, b: Double): Double = a + b @@ -228,9 +228,12 @@ override fun divide(a: Double, b: Double): Double { - **Calling blocking code outside `Promise.async`** — Network calls, delay, etc. must be inside `Promise.async { }` (uses coroutines) - **Storing `NitroModules.applicationContext` in a field** — It can be null at construction time; always access it via a `get()` property - **Not null-checking `applicationContext`** — Always use `?: throw Error("No ApplicationContext set!")` to fail explicitly +- **Silently returning on unavailable APIs** — Throw a clear error instead of returning, logging, or printing +- **Non-final implementation classes** — Make almost all classes `final` unless you really expect subclasses ## Related Skills +- [api-design-best-practices.md](api-design-best-practices.md) — API shape, errors, native state, memory, buffers, hooks, and Harness tests - [native-nitrogen-codegen.md](native-nitrogen-codegen.md) — Must generate specs before implementing - [spec-nitro-json.md](spec-nitro-json.md) — Configure `"kotlin"` in autolinking - [native-implement-swift.md](native-implement-swift.md) — iOS Swift counterpart diff --git a/skills/build-nitro-modules/references/native-implement-swift.md b/skills/build-nitro-modules/references/native-implement-swift.md index 174ee88..c265f4c 100644 --- a/skills/build-nitro-modules/references/native-implement-swift.md +++ b/skills/build-nitro-modules/references/native-implement-swift.md @@ -22,7 +22,7 @@ class HybridMath: NSObject { ```swift import NitroModules -class HybridMath: HybridMathSpec { +final class HybridMath: HybridMathSpec { func add(a: Double, b: Double) throws -> Double { a + b } } ``` @@ -58,7 +58,7 @@ touch ios/HybridMath.swift ```swift import NitroModules -class HybridMath: HybridMathSpec { +final class HybridMath: HybridMathSpec { // Synchronous methods — most generated methods have `throws` func add(a: Double, b: Double) throws -> Double { @@ -182,7 +182,7 @@ func round(value: Double, decimals: Double?) -> Double { ```swift func divide(a: Double, b: Double) throws -> Double { guard b != 0 else { - throw NSError(domain: "Math", code: 1, userInfo: [NSLocalizedDescriptionKey: "Division by zero!"]) + throw RuntimeError("Division by zero!") } return a / b } @@ -214,9 +214,13 @@ var zoom: Double { - **`any HybridSpec` not `HybridSpec`** — In modern Swift, protocol types need the `any` keyword - **Not including the file in podspec** — Swift files must be in the `source_files` glob in `.podspec` - **Using the `override` keyword** — The generated spec is a Swift *protocol*, not a superclass. Conforming methods and properties must NOT use `override` (unlike the Kotlin counterpart, which does). `override` only applies when overriding a superclass member. +- **Using `NSError` or Objective-C types** — Nitro bridges Swift directly; prefer `RuntimeError("...")` or another Swift `Error` +- **Silently returning on unavailable APIs** — Throw a clear runtime error instead of `guard ... else { return }` +- **Non-final implementation classes** — Make almost all classes `final` unless you really expect subclasses ## Related Skills +- [api-design-best-practices.md](api-design-best-practices.md) — API shape, errors, native state, memory, buffers, hooks, and Harness tests - [native-nitrogen-codegen.md](native-nitrogen-codegen.md) — Must generate specs before implementing - [spec-nitro-json.md](spec-nitro-json.md) — Configure `"swift"` in autolinking - [native-implement-kotlin.md](native-implement-kotlin.md) — Android Kotlin counterpart diff --git a/skills/build-nitro-modules/references/spec-hybrid-object.md b/skills/build-nitro-modules/references/spec-hybrid-object.md index 9b97e8f..b28a2a3 100644 --- a/skills/build-nitro-modules/references/spec-hybrid-object.md +++ b/skills/build-nitro-modules/references/spec-hybrid-object.md @@ -53,7 +53,18 @@ rm packages/react-native-math/src/specs/Example.nitro.ts The scaffold creates `Example.nitro.ts` as a placeholder — always replace it with your domain-specific spec. -### 2. Create the spec file +### 2. Design the API shape first + +Before writing methods, apply [api-design-best-practices.md](api-design-best-practices.md): + +- Use properties for cheap state-like getters, for example `readonly isAccelerometerAvailable: boolean`, instead of functions like `isAccelerometerAvailable()`. +- Use `is*`, `has*`, or similar prefixes for boolean properties. +- Use typed structs instead of 3+ params. +- Prefer typed structs/methods/types over `AnyMap` or `Record`. +- Use instance-based APIs and factory HybridObjects when native state, pre-warming, or native resources matter. +- Do not model not-implemented or unavailable platform behaviour as no-op methods; make the native implementation throw or reject explicitly. + +### 3. Create the spec file Name it after the module's domain: `Math.nitro.ts`, `Camera.nitro.ts`, `Crypto.nitro.ts`. @@ -63,7 +74,7 @@ Name it after the module's domain: `Math.nitro.ts`, `Camera.nitro.ts`, `Crypto.n touch packages/react-native-math/src/specs/Math.nitro.ts ``` -### 3. Write the interface +### 4. Write the interface ```typescript import { type HybridObject, NitroModules } from 'react-native-nitro-modules' @@ -87,7 +98,7 @@ interface Math extends HybridObject<{ ios: 'swift'; android: 'kotlin' }> { } ``` -### 4. Choose platform languages +### 5. Choose platform languages In the `HybridObject<{ ... }>` generic: - `ios: 'swift'` — iOS implemented in Swift @@ -99,7 +110,7 @@ For C++ only (both platforms): `HybridObject<{ ios: 'c++'; android: 'c++' }>` > **Note:** Both the `.nitro.ts` spec and `nitro.json` autolinking use `"c++"`. In `nitro.json`, the C++ autolinking entry uses `"all": { "language": "c++", "implementationClassName": "HybridMath" }`. -### 5. Export the HybridObject (Step 10) +### 6. Export the HybridObject (Step 10) After implementing native code, export from `src/index.ts`: @@ -167,8 +178,12 @@ export { camera } - **Forgetting platform languages** — `HybridObject<{}>` without specifying ios/android will fail - **Modifying generated files** — Never edit files in `nitrogen/generated/`; edit only the `.nitro.ts` spec - **Missing export** — The hybrid object won't be usable without the `createHybridObject` call and export +- **Getter methods instead of properties** — Don't create functions that could be simple property getters, such as `isAccelerometerAvailable()` +- **Too many positional params** — Use objects / structs instead of 3+ params +- **Generic maps by default** — Prefer typed structs/methods/types over `AnyMap` or `Record` ## Related Skills +- [api-design-best-practices.md](api-design-best-practices.md) — API shape, errors, native state, memory, buffers, hooks, and Harness tests - [spec-nitro-json.md](spec-nitro-json.md) — Configure autolinking to match the interface name - [native-nitrogen-codegen.md](native-nitrogen-codegen.md) — Run codegen after writing the spec From 062dbc59f3c439949db41961a61378e1d09d6722 Mon Sep 17 00:00:00 2001 From: Laurids Kern Date: Thu, 28 May 2026 15:59:58 +0200 Subject: [PATCH 2/2] docs: add Nitro API abstraction guidance --- .../references/api-design-best-practices.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/skills/build-nitro-modules/references/api-design-best-practices.md b/skills/build-nitro-modules/references/api-design-best-practices.md index fbffe41..7f26d4f 100644 --- a/skills/build-nitro-modules/references/api-design-best-practices.md +++ b/skills/build-nitro-modules/references/api-design-best-practices.md @@ -19,6 +19,16 @@ Use this before writing the `.nitro.ts` spec and keep using it while implementin - **Do not create multiple types in a single file at top level.** Create multiple files for stuff like this. The only exception is truly local types/structs/classes/enums, which can be nested private. - **Make almost all classes `final`.** This is better for performance, unless you really expect them to be overridden. Usually that never happens in Nitro HybridObjects. +## Cross-Platform API Abstractions + +React Native is a cross-platform framework - try to avoid leaking too much platform specific information into public APIs. For example, instead of 1:1 mirroring iOS/Android APIs to TypeScript, try to find common abstractions. + +Try not to overly abstract like the Web/JS-style. The sweet-spot is right in-between. See `react-native-vision-camera` or `react-native-nitro-image` for good, cross-platform abstractions that don't leak platform specific behaviour into user APIs. + +Only rarely, e.g. like the `CameraObjectOutput` which is a native Object Metadata output implemented via `AVCaptureMetadataObjectsOutput` - this is only available on iOS, yet the public APIs don't mention the AVFoundation types as it is unnecessary for the user. + +On the other side, stuff like GPU-powered, zero-copy, or similar concepts are close-to-the-metal APIs that are good to expose to the API user via Nitro, which is something Web-APIs often avoid. Find the sweet-spot here. + ## Error Handling - **Avoid swallowing errors silently.** Don't just early return, don't just log/print. Either throw or reject promise if possible, or have a separate error listener if an error is being sent in a different execution context.