| name | sdk-react-native |
| description | Build, debug, and maintain the Onde Inference React Native / Expo SDK. Covers Expo native module wiring, Metro config for file-linked SDKs, iOS deployment target and Expo autolinking, XCFramework device/simulator split, app group entitlement, HF_HOME/TMPDIR sandbox setup, the native bridge JSON string contract, EngineStatus normalization, and npm publishing. |
| allowed-tools | Read, Write, Edit, Glob, Grep, Bash |
| user-invocable | true |
Skill: React Native / Expo SDK
What This Skill Covers
Building and maintaining the @ondeinference/react-native Expo native module,
wiring the Rust C FFI bridge to TypeScript, debugging Metro/CocoaPods/Xcode
issues, and publishing to npm.
Repository Layout
onde/sdk/react-native/
├── package.json # name: @ondeinference/react-native
├── expo-module.config.json # declares OndeInferenceModule for Expo autolinking
├── tsconfig.json # target ES2020, module commonjs
├── src/
│ ├── index.ts # public API: OndeChatEngine, free functions, types
│ ├── OndeInferenceModule.ts # requireNativeModule("OndeInference") — raw bridge
│ └── types.ts # ChatMessage, SamplingConfig, EngineInfo, etc.
├── build/ # tsc output — gitignored, regenerated by npm run build
├── ios/
│ ├── OndeInference.podspec # CocoaPods spec — vendored_frameworks = XCFramework
│ ├── OndeInferenceModule.swift # Expo Module: Rust C FFI declarations + module def
│ └── rust/
│ ├── OndeReactNative.xcframework # bundles device + simulator slices
│ ├── device/libonde_react_native.a
│ └── simulator/libonde_react_native.a
├── android/ # Android module (not yet wired for Onde)
├── scripts/
│ └── build-rust.sh # builds Rust .a for iOS device + sim, creates XCFramework
└── example/
├── app.json # Expo config — ios.deploymentTarget, appleTeamId
├── babel.config.js # babel-preset-expo only
├── metro.config.js # watchFolders, extraNodeModules, nodeModulesPaths, blockList
├── package.json # dep: "@ondeinference/react-native": "file:.."
└── ios/
├── Podfile # uses use_expo_modules!, deployment from Podfile.properties.json
├── Podfile.properties.json # ios.deploymentTarget: "16.0" — critical, see below
├── OndeExample/
│ ├── OndeExample.entitlements
│ └── Info.plist
└── Pods/ # gitignored
Critical Lessons (Read First)
1. Metro @babel/runtime resolution with file:.. local packages
Symptom: Unable to resolve module @babel/runtime/helpers/asyncToGenerator
from inside the SDK's build/index.js.
Root cause: When Metro processes a file physically located outside the
example app's directory (e.g. in sdk/react-native/build/), Babel transforms
async/await and injects require("@babel/runtime/..."). Metro resolves
modules relative to the file's physical location — but the SDK's own
node_modules/ is blocked (to avoid react-native version conflicts) and the
example's node_modules/ is not in the resolution path for files outside the
example root.
Fix — always required in metro.config.js:
config.resolver.nodeModulesPaths = [path.resolve(__dirname, "node_modules")];
Also required: block the SDK's node_modules to avoid react-native version
conflicts:
config.resolver.blockList = [
new RegExp(
path.resolve(sdkRoot, "node_modules").replace(/\\/g, "\\\\") + "/.*",
),
];
2. Expo autolinking silently skips pods when deployment target is too low
Symptom: pod install completes but OndeInference is not in
Podfile.lock. The app launches but throws "Cannot find native module
'OndeInference'" at runtime.
Root cause: Expo's autolinking Ruby code calls pod.supports_platform?
on each discovered pod. The OndeInference podspec declares
s.platforms = { :ios => '16.0' }. If the project's deployment target
(from Podfile.properties.json) is lower (e.g. the Expo default of 15.1),
supports_platform? returns false and the pod is silently skipped with no
error or warning.
Diagnosis:
xcrun simctl get_app_container booted <bundle-id> data
grep "OndeInference" ios/Podfile.lock
Fix — three places must all agree on iOS 16.0:
sdk/react-native/ios/OndeInference.podspec — s.platforms = { :ios => '16.0' }
example/ios/Podfile.properties.json — "ios.deploymentTarget": "16.0"
example/app.json → expo.ios.deploymentTarget: "16.0"
And after changing Podfile.properties.json, manually patch the Xcode project:
sed -i '' 's/IPHONEOS_DEPLOYMENT_TARGET = 15.1/IPHONEOS_DEPLOYMENT_TARGET = 16.0/g' \
ios/OndeExample.xcodeproj/project.pbxproj
Then run pod install again and verify:
grep "OndeInference" ios/Podfile.lock
Important: expo prebuild --clean regenerates Podfile.properties.json
from app.json. After --clean, you must also manually re-add
"ios.deploymentTarget": "16.0" to Podfile.properties.json if it was not
picked up from app.json.
3. iOS device vs simulator static library — use an XCFramework
Symptom: Build succeeds for simulator but fails with:
ld: building for 'iOS-simulator', but linking in object file built for 'iOS'
Root cause: Both iOS device (aarch64-apple-ios) and iOS simulator
(aarch64-apple-ios-sim) are arm64, so lipo cannot merge them into a fat
binary. They have different Mach-O platform metadata.
Wrong approach: Single libonde_react_native.a → works for device OR
simulator but not both.
Wrong approach: vendored_libraries with two files sharing the same
filename → CocoaPods reports "conflicting names" error.
Correct approach: XCFramework bundling both slices:
mkdir -p ios/rust/device ios/rust/simulator
cp target/aarch64-apple-ios/release/libonde_react_native.a ios/rust/device/
cp target/aarch64-apple-ios-sim/release/libonde_react_native.a ios/rust/simulator/
rm -rf ios/rust/OndeReactNative.xcframework
xcodebuild -create-xcframework \
-library ios/rust/device/libonde_react_native.a \
-library ios/rust/simulator/libonde_react_native.a \
-output ios/rust/OndeReactNative.xcframework
Podspec uses vendored_frameworks (not vendored_libraries):
s.vendored_frameworks = 'rust/OndeReactNative.xcframework'
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule',
'OTHER_LDFLAGS' => '-londe_react_native',
}
CocoaPods automatically selects the correct slice for device vs simulator.
4. The native bridge JSON string contract — never return Swift dicts
Symptom: JSON parse error: unexpected character: o
Root cause: The TypeScript layer receives the native return value and calls
JSON.parse(value). If the native Swift module returns [String: Any] or
[[String: Any]] instead of a String, the Expo bridge serialises it in an
opaque way that is not valid JSON (the string representation starts with
something like {...} or [...] in a non-JSON form that starts with o
when printed).
The contract: All Expo Module methods in OndeInferenceModule.swift that
return complex types MUST return String (the raw JSON from Rust), NOT
[String: Any] or [[String: Any]]. The TypeScript layer owns all JSON
parsing.
Correct Swift:
AsyncFunction("loadDefaultModel") {
(systemPrompt: String?, samplingJson: String?) -> String in
...
return try self.validateJsonResult(result)
}
Function("defaultModelConfig") { () -> String in
let result = self.consumeRustString(onde_default_model_config())
return try self.validateJsonResult(result)
}
Validation helper — validate but do not deserialise:
private func validateJsonResult(_ json: String?) throws -> String {
guard let json = json,
let data = json.data(using: .utf8),
let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any]
else { throw OndeError.invalidResponse }
if let error = dict["error"] as? String {
throw OndeError.inferenceError(error)
}
return json
}
private func validateJsonArray(_ json: String?) throws -> String {
guard let json = json,
let data = json.data(using: .utf8),
(try JSONSerialization.jsonObject(with: data) as? [[String: Any]]) != nil
else { throw OndeError.invalidResponse }
return json
}
5. EngineStatus is lowercase from Rust — normalize in the UI layer
Symptom: Model loads successfully. Alert says "Ready in X.Xs". But the
TextInput placeholder stays at "Load a model first" and remains non-editable.
Root cause: Rust's Display impl emits lowercase status strings:
"ready", "unloaded", "loading", "generating", "error".
The TypeScript UI uses capitalized union type values ("Ready", "Unloaded",
etc.). After refreshStatus() runs, engineStatus holds "ready" which
does not match "Ready" in the isLoaded check.
Fix — normalize at the point of receiving native status:
const normalizeEngineStatus = useCallback((status: string): EngineStatus => {
switch (status.toLowerCase()) {
case "loading": return "Loading";
case "ready": return "Ready";
case "generating": return "Generating";
case "error": return "Error";
default: return "Unloaded";
}
}, []);
const refreshStatus = useCallback(async () => {
try {
const info = await OndeChatEngine.info();
setEngineStatus(normalizeEngineStatus(info.status));
setModelName(info.modelName ?? null);
setApproxMemory(info.approxMemory ?? null);
} catch { }
}, [normalizeEngineStatus]);
6. iOS sandbox filesystem setup — HF_HOME must be set before model load
Symptom: Model load fails immediately with "error 0" or returns an error
containing "HF_HOME is not set".
Root cause: On iOS, ~/.cache is outside the app sandbox. The Rust engine
explicitly requires HF_HOME to be set before any model load on sandboxed
platforms. Without it, load_gguf_model returns an InferenceError::ModelBuild
with the message: "HF_HOME is not set — cannot initialise HF cache."
Fix — call configureApplicationFilesystem() in OnCreate and before each
model load:
private func configureApplicationFilesystem() throws {
let fileManager = FileManager.default
let containerDirectory: URL
if let appGroupDirectory = fileManager.containerURL(
forSecurityApplicationGroupIdentifier: ondeAppGroupIdentifier
) {
containerDirectory = appGroupDirectory
} else {
containerDirectory = try fileManager.url(
for: .applicationSupportDirectory,
in: .userDomainMask, appropriateFor: nil, create: true
)
}
let hfHomeDirectory = containerDirectory.appendingPathComponent("models", isDirectory: true)
let hfHubCacheDirectory = hfHomeDirectory.appendingPathComponent("hub", isDirectory: true)
let tempDirectory = containerDirectory.appendingPathComponent("tmp", isDirectory: true)
try fileManager.createDirectory(at: hfHomeDirectory, withIntermediateDirectories: true)
try fileManager.createDirectory(at: hfHubCacheDirectory, withIntermediateDirectories: true)
try fileManager.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
setenv("HF_HOME", hfHomeDirectory.path, 1)
setenv("HF_HUB_CACHE", hfHubCacheDirectory.path, 1)
setenv("HUGGINGFACE_HUB_CACHE", hfHubCacheDirectory.path, 1)
setenv("TMPDIR", tempDirectory.path, 1)
}
Call it in OnCreate and at the start of loadDefaultModel/loadModel:
OnCreate {
do {
try self.configureApplicationFilesystem()
self.enginePtr = onde_engine_create()
} catch {
self.enginePtr = nil
}
}
AsyncFunction("loadDefaultModel") { ... -> String in
try self.configureApplicationFilesystem()
guard let ptr = self.enginePtr else { throw OndeError.engineNotInitialized }
...
}
7. Shared app group cache path convention
All Onde-powered apps across all SDKs (Tauri, Swift, React Native, Flutter)
share the same HuggingFace cache via the App Group container. The layout is:
<group.com.ondeinference.apps container>/
├── models/ ← HF_HOME
│ └── hub/ ← HF_HUB_CACHE (GGUF files live here)
└── tmp/ ← TMPDIR
The subdirectory is models/, NOT huggingface/.
Do NOT use OndeInference/huggingface/hub/ — this was an early mistake that
caused the app to look in a different place than the Tauri SDK, preventing
cross-app model reuse.
Verifying the shared container is accessible on simulator:
xcrun simctl get_app_container booted <bundle-id> groups
xcrun simctl get_app_container booted <bundle-id> group.com.ondeinference.apps
find "$(xcrun simctl get_app_container booted <bundle-id> group.com.ondeinference.apps)/models/hub" \
-name "*.gguf" -o -name "*.part" -o -name "*.lock" | sort
8. Entitlements required for iOS
The example app's OndeExample.entitlements must contain:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.kernel.increased-memory-limit</key>
<true />
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.ondeinference.apps</string>
</array>
</dict>
</plist>
increased-memory-limit — required for ~1 GB+ model loading on iOS
com.apple.security.application-groups — grants access to the shared model cache
On physical device: the App Group must be registered in the Apple Developer
Portal under Identifiers → App Groups before Xcode can provision it.
On the iOS Simulator the entitlement is granted automatically by the simulator
runtime — no provisioning profile needed.
9. FlatList swallows taps — TextInput never gets focus
Symptom: After model loads, the TextInput placeholder is correct but
tapping it does nothing. The keyboard never appears.
Root cause: FlatList on iOS intercepts touch events and by default
dismisses the keyboard on scroll, which prevents child inputs from receiving
focus when tapping in certain layout configurations.
Fix:
<FlatList
...
keyboardShouldPersistTaps="handled"
/>
10. Do not use the iOS Simulator to validate model loading
The iOS Simulator is unreliable for validating actual LLM model loading with
this stack:
- Metal-backed inference behaves differently in the simulator
- Large GGUF model loading (~941 MB) may hang or fail in the simulator
- Shared app group container behavior can differ
Use the Simulator for:
- Build verification
- UI layout and interaction
- Entitlement/container path checks (
xcrun simctl get_app_container ...)
Use a physical iPhone running iOS 16+ for:
- Actual model download and load
- Metal inference correctness
- Memory behavior
11. Diagnosing a hung model load
When the app stays in "Loading" state indefinitely:
- Check the shared container has no stale partial downloads:
find "$(xcrun simctl get_app_container booted <bundle-id> group.com.ondeinference.apps)" \
-type f \( -name "*.part" -o -name "*.lock" \) | sort
rm -rf "<container>/models/hub/models--<org>--<model>"
- Check the app group resolves:
xcrun simctl get_app_container booted <bundle-id> groups
- Check native logs:
xcrun simctl spawn booted log show --style compact --last 5m \
--predicate 'process == "OndeExample" AND (
eventMessage CONTAINS[c] "HF_" OR
eventMessage CONTAINS[c] "models" OR
eventMessage CONTAINS[c] "onde"
)' | tail -100
- Verify
HF_TOKEN was baked in at Rust build time. On iOS,
TokenSource::CacheToken cannot read ~/.cache/huggingface/token because that
path is outside the sandbox. HF_TOKEN must be set as an environment variable
when building the Rust static library:
export HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxx
./scripts/build-rust.sh ios
option_env!("HF_TOKEN") in src/inference/token.rs bakes it into the binary
at compile time. Setting it after the fact has no effect.
Architecture
┌──────────────────────────────────────────────────────┐
│ React Native / TypeScript │
│ OndeChatEngine, free functions (index.ts) │
└─────────────────────┬────────────────────────────────┘
│ JSON strings over Expo bridge
▼
┌──────────────────────────────────────────────────────┐
│ OndeInferenceModule.swift (Expo Module) │
│ - configureApplicationFilesystem() │
│ - validateJsonResult() / validateJsonArray() │
│ - AsyncFunction / Function definitions │
└─────────────────────┬────────────────────────────────┘
│ C FFI via @_silgen_name
▼
┌──────────────────────────────────────────────────────┐
│ libonde_react_native.a (Rust static lib) │
│ C API: onde_engine_create, onde_engine_load_*, ... │
│ Rust engine: ChatEngine, mistral.rs, Metal/CPU │
└──────────────────────────────────────────────────────┘
The Swift module does not use UniFFI. It calls the Rust C API directly via
@_silgen_name declarations, communicating via JSON strings over the Expo
Module bridge.
This is different from the Swift XCFramework SDK (which uses UniFFI-generated
onde.swift) and the Kotlin SDK (which uses UniFFI-generated onde.kt).
Expo Module Definition Reference
All methods in OndeInferenceModule.swift follow this pattern:
| Expo construct | Use for |
|---|
Function(name) | Synchronous calls (isLoaded, setSystemPrompt, etc.) |
AsyncFunction(name) | All calls that invoke Rust (model load, inference, etc.) |
OnCreate | Engine initialisation — run configureApplicationFilesystem() here |
OnDestroy | Engine cleanup — call onde_engine_destroy |
Return types:
- Simple sync functions:
Bool, Int
- All complex types:
String — raw JSON from Rust, never [String: Any]
TypeScript Bridge Contract
OndeInferenceModule.ts — raw native interface
All async methods return Promise<string> (raw JSON strings from Rust):
export interface OndeInferenceNativeModule {
isLoaded(): boolean;
setSystemPrompt(prompt: string): void;
clearHistory(): number;
loadDefaultModel(systemPrompt: string | null, samplingJson: string | null): Promise<string>;
sendMessage(message: string): Promise<string>;
info(): Promise<string>;
history(): Promise<string>;
defaultModelConfig(): string;
defaultSamplingConfig(): string;
}
index.ts — public API layer
Serialises inputs to JSON, calls native, deserialises JSON responses:
function serializeSampling(config: SamplingConfig): string {
return JSON.stringify({ temperature: config.temperature, top_p: config.topP, ... });
}
function parseResult<T>(json: string): T {
const parsed = JSON.parse(json);
if (parsed.error) throw new OndeError(parsed.error);
return parsed as T;
}
Metro Config for file:.. SDK Development
const { getDefaultConfig } = require("expo/metro-config");
const path = require("path");
const sdkRoot = path.resolve(__dirname, "..");
const config = getDefaultConfig(__dirname);
config.watchFolders = [sdkRoot];
config.resolver.extraNodeModules = {
"@ondeinference/react-native": sdkRoot,
};
config.resolver.nodeModulesPaths = [path.resolve(__dirname, "node_modules")];
config.resolver.blockList = [
new RegExp(
path.resolve(sdkRoot, "node_modules").replace(/\\/g, "\\\\") + "/.*",
),
];
module.exports = config;
Rust C API Surface
The Swift module binds to these C functions via @_silgen_name:
| Function | Returns | Notes |
|---|
onde_engine_create | UnsafeMutableRawPointer? | Allocates engine |
onde_engine_destroy | void | Frees engine |
onde_engine_load_default_model | UnsafeMutablePointer<CChar>? | JSON result string |
onde_engine_load_model | UnsafeMutablePointer<CChar>? | JSON result string |
onde_engine_unload_model | UnsafeMutablePointer<CChar>? | JSON result string |
onde_engine_is_loaded | Bool | Sync |
onde_engine_info | UnsafeMutablePointer<CChar>? | JSON result string |
onde_engine_send_message | UnsafeMutablePointer<CChar>? | JSON result string |
onde_engine_generate | UnsafeMutablePointer<CChar>? | JSON result string |
onde_engine_history | UnsafeMutablePointer<CChar>? | JSON array string |
onde_engine_clear_history | UInt64 | Sync, count |
onde_engine_set_system_prompt | void | Sync |
onde_engine_set_sampling | void | Sync |
onde_engine_push_history | void | Sync |
onde_free_string | void | Must call for every non-null CChar* result |
onde_default_model_config | UnsafeMutablePointer<CChar>? | JSON result string |
onde_qwen25_1_5b_config | UnsafeMutablePointer<CChar>? | JSON result string |
onde_qwen25_3b_config | UnsafeMutablePointer<CChar>? | JSON result string |
onde_default_sampling_config | UnsafeMutablePointer<CChar>? | JSON result string |
onde_deterministic_sampling_config | UnsafeMutablePointer<CChar>? | JSON result string |
onde_mobile_sampling_config | UnsafeMutablePointer<CChar>? | JSON result string |
Always call onde_free_string after consuming each result pointer. The
Rust side allocates the string; Swift is responsible for freeing it.
private func consumeRustString(_ ptr: UnsafeMutablePointer<CChar>?) -> String? {
guard let ptr = ptr else { return nil }
let str = String(cString: ptr)
onde_free_string(ptr)
return str
}
build-rust.sh — iOS Build Flow
cargo build --manifest-path rust/Cargo.toml --target aarch64-apple-ios --release
cargo build --manifest-path rust/Cargo.toml --target aarch64-apple-ios-sim --release
mkdir -p ios/rust/device ios/rust/simulator
cp target/aarch64-apple-ios/release/libonde_react_native.a ios/rust/device/
cp target/aarch64-apple-ios-sim/release/libonde_react_native.a ios/rust/simulator/
rm -rf ios/rust/OndeReactNative.xcframework
xcodebuild -create-xcframework \
-library ios/rust/device/libonde_react_native.a \
-library ios/rust/simulator/libonde_react_native.a \
-output ios/rust/OndeReactNative.xcframework
After rebuilding, run pod install in example/ios/ and then
npx expo run:ios --device or npx expo run:ios (simulator).
Running on Physical Device
npx expo run:ios --device
For a physical device build you need:
- Apple Developer team set in
app.json → expo.ios.appleTeamId
- The App Group
group.com.ondeinference.apps registered in the developer portal
- The App Group capability enabled on the App ID
com.apple.developer.kernel.increased-memory-limit in entitlements
Blank white screen on device = Metro bundle not loading. Ensure:
- Metro is running (
npx expo start --clear)
- Phone and Mac are on the same network (or use Tunnel)
- Rebuild after native changes (
npx expo run:ios --device — not just expo start)
.gitignore for the SDK
node_modules/
build/ # tsc output
rust/target/ # Rust build artifacts
ios/build/
ios/rust/*.a
ios/rust/*.xcframework
ios/rust/device/
ios/rust/simulator/
android/build/
android/src/main/jniLibs/
example/.expo/
example/android/
example/ios/build/
example/ios/Pods/
example/ios/.xcode.env.local
.DS_Store
*.xcuserstate
npm Publishing
cd sdk/react-native
npm run build
npm publish
The .npmignore excludes:
example/
rust/
node_modules/
src/ (consumers use the compiled build/)
The package.json files field explicitly includes:
build/, ios/, android/, scripts/, expo-module.config.json
Common Pitfalls Table
| Pitfall | Symptom | Fix |
|---|
Missing nodeModulesPaths in metro.config.js | Cannot resolve @babel/runtime/helpers/asyncToGenerator | Add config.resolver.nodeModulesPaths = [path.resolve(__dirname, "node_modules")] |
| Deployment target mismatch | Pod installs but OndeInference missing from Podfile.lock | Set "ios.deploymentTarget": "16.0" in Podfile.properties.json AND app.json |
Single .a file for device + simulator | Linker error: "building for iOS-simulator but linked for iOS" | Use xcodebuild -create-xcframework to bundle both slices |
Two .a files with same name in vendored_libraries | CocoaPods "conflicting names" error | Use vendored_frameworks with an XCFramework instead |
Swift returning [String: Any] from Expo Module | JSON parse error: unexpected character: o | Return String from all Expo Module functions; validate but do not deserialise |
HF_HOME not set on iOS | "error 0" or model load fails immediately | Call configureApplicationFilesystem() in OnCreate and before model load |
Wrong cache path (OndeInference/huggingface/hub/) | Models not shared across Onde apps | Use <container>/models/hub/ to match Tauri/Swift convention |
EngineStatus lowercase from Rust | Input stays disabled after model loads; status shows as "ready" not "Ready" | Normalize in refreshStatus() with switch(status.toLowerCase()) |
FlatList swallows taps | TextInput can't be focused | Add keyboardShouldPersistTaps="handled" to FlatList |
| Model hangs on Simulator | Stuck in loading indefinitely | Test model loading on a physical device; Simulator is unreliable for this |
Stale .part/.lock files | Model download never completes on retry | Delete models--<org>--<model> from the shared container and retry |
HF_TOKEN not baked in | Auth fails on HuggingFace download; "401 Unauthorized" in Rust logs | Set HF_TOKEN env var before building the Rust static library |
expo prebuild --clean wipes Podfile.properties.json | Deployment target reverts to 15.1; autolinking drops OndeInference | Re-add "ios.deploymentTarget": "16.0" to Podfile.properties.json after every --clean |
| Physical device blank white screen | App launches, nothing renders | Ensure Metro is running and on same network; use npx expo run:ios --device |
Running with expo start instead of expo run:ios | "Cannot find native module 'OndeInference'" | Custom native modules require a native build — use expo run:ios not Expo Go |