Important
This project is 100% AI-generated. It began as a goal given to Codex and took six days of iterative implementation, reproduction, testing, and correction—not a one-shot generation. We did not meter the exact cost, but estimate that the work used at least a few tens of thousands of dollars' worth of inference. The goal is to bring Turbo's server-driven navigation, Frames, Streams, forms, and Action Cable model to Expo React Native while keeping the UI native.
Contributions are welcome, with one caveat: please open a guided bug report or change request with context, not a code patch. Pull requests are disabled because the maintainers did not write this code and cannot responsibly judge its implementation-level best practices. Write for an agent starting with zero context: separate facts from hypotheses; include the problem and impact, exact reproduction and evidence, expected behavior and constraints, plus how the fix should be validated. You may investigate locally with your own agent and paste its compressed findings and proposed solution. Our coding agents will reproduce the problem, implement the change, and validate the fix.
Expo Turbo lets a Rails application send Turbo-style pages and updates to an Expo React Native application.
Rails renders XML instead of browser HTML. The Expo app reads that XML and maps its element names to React Native components that the app has already registered. This gives Rails and Expo a shared format for documents, links, forms, Frames, Streams, and Action Cable updates while the mobile app keeps control of its native UI and behavior.
Turbo already gives Rails applications a way to update browser pages without building a separate client-side API for every interaction. Expo Turbo brings the supported parts of that model to React Native, where there is no browser DOM.
It is not a browser or a way for the server to run arbitrary code in an app. Rails may select only components and values that the Expo app explicitly allows. The app still owns navigation, authentication, networking, native styles, accessibility, and product state.
- A Rails controller uses the
expo_turbo-railsgem to render a validated XML document, Frame, or Stream response. - The
expo-turboTypeScript package parses that response and applies the supported Turbo behavior. - The Expo app renders registered React Native components and supplies the platform-specific behavior the runtime needs.
- Rails continues to own routes, authorization, views, caching, and broadcasts.
In the phrase host-neutral XML protocol runtime:
- XML protocol means Rails and Expo exchange a defined set of XML documents and updates.
- Runtime means the package reads those messages and carries out supported visits, form submissions, Frame loads, and Stream updates.
- Host-neutral means the core does not assume a particular app, router, network client, storage system, or authentication scheme. The Expo app provides those pieces through adapters.
The implementation targets the supported native equivalents of Turbo 8.0.23
semantics. It does not try to copy browser-only DOM behavior.
Important
0.1.0 is the stable release published on 2026-07-27. The package, gem,
standalone examples, automated conformance suite, and installed iOS and
Android simulator Release audits are complete for the supported surface. The
physical Android and iOS union suites also pass all 71 contracts and 115
atomic observations on each platform, including native provider multipart
proof. Manual VoiceOver, TalkBack, and browser screen-reader evidence remains
an explicit follow-up and is not claimed by the 0.1.0 compatibility surface.
To inspect and test the current source:
git clone https://github.com/noscrubs-dev/expo-turbo.git
cd expo-turbo
bun install --frozen-lockfile
bun run checkStart with the getting-started guide. The standalone Expo app and standalone Rails host are the canonical integration examples.
| Area | Current status |
|---|---|
| TypeScript package | expo-turbo@0.1.0; stable and clean-imported through all six public entrypoints |
| Rails gem | expo_turbo-rails 0.1.0; clean-installed against turbo-rails 2.0.10 and 2.0.23 |
| Protocol baseline | Turbo 8.0.23; all 26 upstream functional-suite families classified |
| Native Release evidence | Installed iOS Simulator and Android Emulator builds pass; physical Android and iOS each pass 71/71 shared contracts |
| Stable publication | v0.1.0, published 2026-07-27 from one verified frozen npm/gem candidate |
See the machine-readable compatibility manifest and 0.1.0 release-readiness checklist for the exact boundary.
bun add expo-turbo
bundle add expo_turbo-railsA checked item is implemented and covered by the current public source and test suite. This is the detailed technical reference; newcomers can start with the overview and examples above. “Native equivalent” means the protocol outcome is supported through explicit native adapters; it does not claim browser DOM behavior.
- Strict, namespace-aware UTF-8 XML documents
- Ordered multi-root Turbo Stream fragments
- Byte, depth, node, attribute, text, and Stream-action limits
- DTD, entity-declaration, and processing-instruction rejection
- Preserved mixed content, CDATA, entities, and
xml:space - Addressable document tree with stable keys and unique-ID enforcement
- Case-sensitive XML selectors for supported structural target queries
- Deterministic diagnostics and redacted typed protocol errors
- Zod-backed typed component, component-action, and custom Stream registries
- Explicit attribute codecs, child slots, aliases, and capability hashes
-
ExpoTurboProvider,ExpoTurboRoot, boundaries, hooks, and scoped state - Small-slice external-store subscriptions and deterministic disposal
- Semantic style-token and direction adapters
- Host-owned focus, scroll, visibility, storage, navigation, and observability adapters
- Accessible busy, pending, disabled, validation, retry, and announcement surfaces
- Same-origin document and Frame GETs with Turbo request headers
- Redirect, empty, authoritative error, wrong-MIME, malformed, abort, and supersession outcomes
-
advance,replace, and nativerestorevisits - Ten-entry snapshot cache, previews, preloads, and touch-native press-in prefetch
- Host-acknowledged history identity plus root-scroll restoration
- Same-document and host-routed cross-document native anchors
- Eager, lazy, nested, recurse, disabled, targeted, and programmatic Frames
- Matching-Frame extraction, missing-Frame handling, redirects, and request epochs
- Native Frame loading, busy/complete, autofocus, autoscroll, and refresh-morph equivalents
- Host-registered successful controls and submitter precedence
- GET, URL-encoded,
text/plain, and multipart request planning - Rails method normalization and nested/repeated parameter names
- Constraint validation, async confirmation, duplicate prevention, and
submits-with - Matching Frame, direct Stream,
303,422,4xx/5xx,201, and204handling - Component-owned draft preservation through compatible morphs
- Bounded host-provided Blob/file entries and native picker adapters
- Installed Android Emulator multipart proof through the real Rails host
- Physical Android fallback-Blob and Files-picker multipart proof
- Physical iOS fallback-Blob and Files-picker multipart proof
-
append,prepend,replace,update,remove,before, andafter - Exact
target, structuraltargets, template cloning, and collision semantics - Per-action ordering, isolated failures, custom actions, and lifecycle hooks
- Refresh debounce, request-ID suppression, replace/morph, and scroll policy
- Compatible stable-ID and anonymous native reconciliation
- Permanent application, Frame, and Cable-source preservation
- Component preserve/reset policy and bounded focus restoration
- Document, Frame, Stream, element, and attribute morph lifecycle equivalents
- Strict
actioncable-v1-jsonwire codec and same-origin endpoint admission - One-socket WebSocket adapter with exact identifier refcounting
- Welcome, confirmation, rejection, ping, disconnect, retry, and heartbeat handling
- Lifecycle/reachability suspension, credential rotation, and bounded retry policy
- Document- and Frame-aware reconciliation after reconnect
- Public XML Stream namespaces and protected host-authorized subscriptions
- Short-lived header-ticket, resource-grant, revocation, and rotation example
- Physical Android lifecycle, network, protected-Cable, and renderer-flush evidence
- Physical iOS lifecycle, network, protected-Cable, and renderer-flush evidence
- Route-free Engine and opt-in
ExpoTurbo::Rails::Controller - Distinct
application/vnd.expo-turbo+xmldocument/Frame MIME type - Confined host-owned XML view roots and structural template validation
- Component/style capability admission and duplicate-ID rejection
- Frame,
dom_id, cache-variation, and structural test helpers - All built-in Stream tags, sibling responses, immediate/queued broadcasts, and refresh debounce
- Public and protected Action Cable source/channel/broadcast APIs
- Rails/Action Cable 7.2–8.1 and
turbo-rails2.0.10–2.x dependency bounds - Real matrix coverage for
turbo-rails2.0.10and2.0.23
- Shared TypeScript/Ruby protocol fixtures
- All 26 pinned Turbo 8.0.23 functional-suite families classified
- Executable official-Turbo differentials for the meaningful shared surface
- Package, gem, Expo, Rails, boundary, security, and production-export gates
- Installed iOS Simulator Release audit through real Rails
- Installed signed Android Emulator Release audit through real Rails
- Automated web WCAG 2.0/2.1 A/AA axe audit and accessibility-tree checks
- Paired candidate build, clean installs, checksums, and provenance proof
- Physical Android conformance evidence: 71/71 contracts and 115/115 observations
- Physical iOS conformance evidence: 71/71 contracts and 115/115 observations
- Manual VoiceOver, TalkBack, and browser screen-reader evidence
- Final paired candidate from the final gated commit
- Stable npm/RubyGems publication and clean registry verification
Expo Turbo reuses Turbo's message format and server behavior where they make
sense on native platforms. It is not a browser DOM port of
@hotwired/turbo.
- Browser DOM targets, bubbling/composed paths,
<head>/script/CSS behavior, selection ranges, shadow DOM, hover, and physical repaint timing are N/A. - Components, component actions, native styles, navigation/history mapping, focus/scroll/visibility handles, credentials, identity, origin policy, retry values, and product state belong to the Expo host.
- Routes, controllers, views, authorization, cache inputs, tenant policy, grants, and product broadcasts belong to the Rails host.
- Action Cable provides online delivery; missed-message correctness uses canonical reconciliation rather than durable replay.
- Offline mutation replay is outside the Turbo contract.
- Server XML can select only installed names and validated values. It cannot import or execute code.
- Expo Turbo has no JSON fallback and does not require migration of an existing renderer.
| Import | Purpose |
|---|---|
expo-turbo |
Version/status constants and combined public surface |
expo-turbo/core |
Parser, tree/session, visits, Frames, forms, Streams, lifecycle, and errors |
expo-turbo/adapters |
Host-neutral adapter interfaces and provided transport helpers |
expo-turbo/react |
Provider, renderer, boundaries, and React hooks |
expo-turbo/registry |
Typed component/action registries and attribute codecs |
expo-turbo/testing |
Reserved testing boundary; no runtime APIs in 0.1.0 |
expo_turbo/rails |
Rails Engine, controller concern, helpers, broadcasts, and Cable integration |
expo_turbo/rails/testing |
Opt-in strict structural XML test helpers |
Deep source imports are unsupported.
| Dependency | Supported or validated range |
|---|---|
| Node.js | >= 20.12 |
| Bun source toolchain | >= 1.3.14 |
| React peer | >= 19.1 |
| Primary Expo example | Expo SDK 57, React Native 0.86, Hermes |
| Ruby | >= 3.2 |
| Rails / Action Cable | >= 7.2, < 8.2 |
turbo-rails |
>= 2.0.10, < 3; matrix pins 2.0.10 and 2.0.23 |
| Protocol baseline | Turbo 8.0.23 |
src/— public TypeScript package source.rails/— publicexpo_turbo-railsgem and non-route-owning Rails Engine.example/expo/— standalone native consumer and compatibility gallery.example/rails/— standalone Rails and Action Cable host.protocol/— compatibility manifest, contract, and shared fixtures.docs/— getting started, decisions, release readiness, and evidence..maestro/— checked-in native interaction flows.
The repository root is the publishable TypeScript package. It is intentionally not a package-manager workspace; both examples keep independent dependency state.
Run the TypeScript package:
bun install --frozen-lockfile
bun run checkRun and build the Ruby gem:
cd rails
bundle install
bundle exec ruby "$(bundle show rake)/exe/rake"
bundle exec ruby "$(bundle show rake)/exe/rake" buildInstall each example independently, then run both checks from the repository root:
cd example/expo && bun install --frozen-lockfile
cd ../rails && bundle install
cd ../..
bun run examples:check- Getting started
- Protocol and authoring contract
- Compatibility manifest
- 0.1.0 release readiness
- iOS Simulator Release evidence
- physical iOS Release evidence
- Android Emulator Release evidence
- Web accessibility evidence
- Changelog
The manual Release workflow defaults to a non-publishing paired candidate. Its
reviewer-gated publish mode may consume only a successful candidate for the
exact final main commit and must reuse the downloaded npm and gem bytes
without rebuilding them.
See CONTRIBUTING.md before proposing changes and SECURITY.md for private vulnerability reporting.