Please open an issue before starting to work on a new feature or a fix to a bug you encountered. This will prevent you from wasting your time on a feature that's not going to be merged, because for instance it's out of scope. If there is an existing issue present for the matter you want to work on, make sure to post a comment saying you are going to work on it. This will make sure there will be only one person working on a given issue.
All work on Voltra happens directly on GitHub. Contributors send pull requests which go through the review process.
Working on your first pull request? You can learn how from this free series: How to Contribute to an Open Source Project on GitHub.
- Fork the repo and create your branch from
main(a guide on how to fork a repository). - Run
npm installto install all required dependencies. - Build the plugin:
npm run build --workspace @use-voltra/expo-plugin. - Now you are ready to make changes.
The JavaScript/TypeScript code has two separate entry points that must be maintained as independent boundaries:
- Client entry (
packages/voltra/src/index.ts): React Native code that runs in the app. Exports JSX components, hooks, and the imperative API for managing Live Activities. - Server entry (
packages/voltra/src/server.ts): Node.js code for rendering Voltra components to string payloads. Used for server-side rendering and push notification payloads.
The Expo plugin in packages/expo-plugin/src/ handles all Xcode project setup during expo prebuild:
- Creates the widget extension target with proper build settings
- Copies template files from
ios-files/(widget bundle, assets, Info.plist) into the extension target - Configures CocoaPods to include the
VoltraWidgetsubspec in the extension target - Sets up entitlements for App Groups (optional, for event forwarding)
- Configures push notifications (optional)
Voltra's Swift code lives in ios/ and is distributed as a CocoaPods package with multiple subspecs:
# From ios/Voltra.podspec
s.subspec 'Core' do |ss|
# React Native bridge module (auto-linked by Expo)
ss.source_files = ["app/**/*.swift", "shared/**/*.swift", "ui/**/*.swift"]
end
s.subspec 'Widget' do |ss|
# Widget extension code (used by Live Activity target)
ss.source_files = ["shared/**/*.swift", "ui/**/*.swift", "target/**/*.swift"]
endCoresubspec: Contains the React Native module (app/), shared code (shared/), and UI components (ui/). Auto-linked by Expo in the main app.Widgetsubspec: Contains shared code, UI components, and widget-specific files (target/). Used by the Live Activity extension target.
This separation ensures the widget extension doesn't include unnecessary React Native dependencies.
Files in ios-files/ are copied by the config plugin into the generated widget extension:
VoltraWidgetBundle.swift— Widget bundle entry pointAssets.xcassets/— Asset catalog for the extensionInfo.plist— Extension configuration
Component props are kept in sync between TypeScript and Swift via a custom code generator. The single source of truth is:
data/components.json
This file defines all components, their parameters, types, and short names used for payload compression.
npm run generateThis generates:
- TypeScript prop types:
src/jsx/props/*.ts - Swift parameter structs:
ios/ui/Generated/Parameters/*.swift - Component ID mappings:
src/payload/component-ids.tsandios/shared/ComponentTypeID.swift - Short name mappings:
src/payload/short-names.tsandios/shared/ShortNames.swift
data/components.json first, then run the generator. Do not manually edit generated files (marked with .generated).
Live Activity payloads have strict size limits imposed by iOS. Voltra includes tests that track payload sizes for real-world examples.
The test in src/__tests__/payload-size.node.test.tsx renders example components and snapshots their compressed payload size:
it('BasicLiveActivityUI', async () => {
const size = await getPayloadSize({
lockScreen: <BasicLiveActivityUI />,
})
expect(size).toMatchSnapshot()
})If your changes affect payload size, the tests will fail. This is intentional:
- Size decreased? Great! Run
npm test -- -uto update snapshots and lock in the improvement. - Size increased? Investigate carefully. Is the increase justified? Can it be optimized? Only update snapshots after confirming the increase is necessary.
The payload schema has a version number to support forward compatibility. When the Swift code receives a payload with a newer version than it understands, it renders empty instead of crashing.
The version is defined in two places that must stay in sync:
- TypeScript:
packages/voltra/src/renderer/renderer.ts→VOLTRA_PAYLOAD_VERSION - Swift:
packages/voltra/ios/shared/VoltraPayloadMigrator.swift→currentVersion
Increment the version when making breaking changes to the payload schema:
- Adding required fields that old Swift code wouldn't understand
- Changing the structure of existing fields
- Renaming keys in a way that breaks parsing
You do not need to increment for:
- Adding optional fields (old Swift code will ignore them)
- Bug fixes that don't change the schema
- Adding new component types (they render as
EmptyViewif unknown)
When you increment the version, add a migration in Swift to upgrade old payloads:
- Increment
currentVersionin both TypeScript and Swift - Create a migration struct implementing
VoltraPayloadMigration - Register it in the
migrationsdictionary
// Example: V1ToV2Migration.swift
struct V1ToV2Migration: VoltraPayloadMigration {
static let fromVersion = 1
static let toVersion = 2
static func migrate(_ json: JSONValue) throws -> JSONValue {
// Transform v1 payload to v2 format
// Update the version field
var result = json
result["v"] = .int(2)
return result
}
}
// In VoltraPayloadMigrator.swift:
private static let migrations: [Int: any VoltraPayloadMigration.Type] = [
1: V1ToV2Migration.self,
]This ensures users with older apps can still receive updates from newer servers.
The example/ directory contains an Expo app for testing changes.
# 1) Build the plugin
npm run build --workspace @use-voltra/expo-plugin
# 2) Install example dependencies
(cd example && npm install)
# 3) Prebuild for iOS
(cd example && npx expo prebuild -p ios)
# 4) Run on iOS
(cd example && npx expo run:ios)If iterating on the plugin, rebuild after each change in packages/expo-plugin/src/.
Run the following checks before opening a pull request:
# Linting
npm run lint:libOnly
# Type checking
npm run build
# Unit tests
npm test
# Format check
npm run format:checkIf formatting fails, run npx prettier --write . to fix it.
When you are ready to have your changes incorporated into the main codebase, open a pull request.
This repository follows the Conventional Commits specification. Please follow this pattern in your pull request titles. Keep in mind your commits will be squashed before merging and the title will be used as a commit title.
- Tests pass (
npm test) - Linting passes (
npm run lint:libOnly) - Formatting is correct (
npm run format:check) - If props changed, generator was run (
npm run generate) - If payload size changed, snapshots were intentionally updated
- Documentation updated if needed
By contributing to Voltra, you agree that your contributions will be licensed under its MIT license.