Readiness: Experimental
Targets
React Native target
React Native is a target in uf, not a flag that turns the web app sideways. The shared part is the Flow toolchain and the file-system route table; the native part is a Metro bundle, platform-specific route files, a navigator contract and test-tree queries that do not pretend there is a DOM.
What you will be able to do: configure the React Native target, write native route files, bundle with Metro, navigate natively, test against a native tree, and weigh it against Expo.
What you need first: a project — Your first project — and Routing for the route table the native files extend.
The current line
This is the honest status.
| Piece | Status | What exists |
|---|---|---|
| Package facade | Implemented | @uniflowed/react-native re-exports the app's real react-native peer |
| Route target | Implemented | uf build --target native, ios and android select native route files |
| Metro config | Implemented | withUniflowedMetro composes with Expo's and React Native's default Metro configs, and loads in the plain Node Metro runs |
| Dev loop | Implemented through the project's own CLI | uf dev --target native runs expo start or react-native start once the Metro config composes uf's transformer, and prints the address a device connects to |
| Native route table | Implemented | router.ios.js, router.android.js and router.native.js, written by uf dev and uf build for Metro to bundle, and rewritten while uf dev runs |
| Native router | Implemented | @uniflowed/router/native-navigation renders file routes through app-owned React Navigation stacks, tabs and custom navigator factories; native remains the lower-level contract |
| Component tests | Implemented | uf test --target react-native uses React Native's official preset; @uniflowed/react-native-testing/native renders real components through React Native Testing Library and drives press, text and scroll handlers |
| Native host and layout | App-owned | Tests mock native modules; device layout, views and the app shell remain React Native's and Expo's |
| App runtime, native builds, updates | Expo's, by design | Expo Go or a development build, the SDK's native modules, prebuild, EAS Build and EAS Update; uf composes with them rather than rebuilding them |
The important distinction is the one between contract and runtime. uf can already say which route file belongs in a native bundle, what Metro needs to resolve, which navigator event or screen call a route becomes, and how to query the tree a native renderer produced, and it starts the project's own dev server for a device. It does not ship a native app runtime: that stays with Expo, or with the React Native CLI project.
That makes this useful for a Flow-first toolchain, routes and tests around React Native inside an app platform, and not a replacement for that platform.
Commonplace example
examples/simple-sns-native
is a separate Expo app with the web Commonplace examples' screens, members and
visual language: a feed with channels, search, a composer and reactions, clips,
a private inbox with conversations, settings, and account creation. It uses
StyleX native objects, bundled images and app-owned React Navigation factories.
Screen components live beside their routes. The same model the web examples keep
in a service stays in memory here, behind an asynchronous interface, so the
screens are written as the web's are: reads suspend under use, writes are
Actions with useActionState and useOptimistic, and results are handled with
Flow's match and typed with renders. The separate GraphQL example
demonstrates the BFF and backend.
Run uf install, uf dev --target ios (or android) and uf test from that
example's directory. CI exercises the real input and navigation components and
builds both platform bundles. Device and simulator testing remain separate.
The usable slice
Use the native target today inside an Expo or React Native CLI app that needs uf to keep Flow, routes, Metro and tests aligned. Do not use it as the thing that creates, builds and submits the app: that is Expo's job, and the two compose.
| You want to | Use | Still app-owned |
|---|---|---|
| Run the app on a phone or simulator with Fast Refresh | uf dev --target native | Expo Go or a development build, and the native project |
| Keep web and native routes in one file tree | $page.native.js, $page.ios.js, $page.android.js | Screen registration and app bootstrap |
| Make Metro understand uf's package and file conventions | withUniflowedMetro | Metro cache policy, native module config, asset plugins |
| Turn a uf path into a native screen call | createNativeScreenManifest, createNativeScreenRouter or createNativeRouter | React Navigation, Expo Router, or a custom navigator |
| Query or mount a native test tree | render, createNativeScreen, within, getByRole, getByLabelText | The real native host, layout and platform views |
| Keep unsupported native tests visible | it.skipBecause | The future host config and renderer |
That boundary is intentionally narrow. It lets a Flow-first codebase move native work out of ad-hoc conventions without pretending uf has a dev client, native module registry, OTA story or store-submission pipeline.
The smallest useful proof is:
$ uf build --target native
$ uf test npm/router/native.test.js npm/react-native-testing/react-native-testing.test.js
The first command proves the route table and manifest can be generated for the native target. The second should query trees and navigator payloads; it should not claim to cover host layout or platform views.
Configure the target
app.framework: "react-native" makes the native target the default application
target. app.targets is the promise the project keeps satisfying; it says the
project has a React Native surface at all.
// @flow
import { defineConfig } from "@uniflowed/config";
export default defineConfig({
app: {
framework: "react-native",
targets: ["react-native"],
},
});
Then a build can name the app surface:
uf build --target nativeor narrow it to one platform:
uf build --target iosuf build --target androidreact-native is accepted as a spelling of the same application target as
native. Without --target, a react-native framework project builds the
native route table; a regular uf or React project builds web.
--target means something different with --compile: then it is a standalone
binary platform triple such as aarch64-apple-darwin. The build command keeps
those meanings apart and says which one you asked for when they are confused.
Route files
Native routes use the same route root and the same segment grammar as web routes. What changes is the reserved file variant the scanner chooses.
app/
$layout.js
$page.native.js
$page.web.js
settings/
$page.android.js
$page.ios.js
$page.native.js
The precedence is target-specific and deliberately the same on the Rust and JavaScript scanners:
| Build target | File order |
|---|---|
web | $page.web.js, then $page.js |
native | $page.native.js, then $page.js |
ios | $page.ios.js, then $page.native.js, then $page.js |
android | $page.android.js, then $page.native.js, then $page.js |
Layouts, route handlers, loading boundaries, templates, not-found boundaries and error boundaries follow the same suffix rule. A native build therefore does not quietly use a web page just because it was the only file in the directory: if there is a more specific native file, that is the route.
// @flow
// app/settings/$page.native.js
import { Pressable, Text, View } from "@uniflowed/react-native";
export component Page() {
return (
<View>
<Text>Settings</Text>
<Pressable accessibilityRole="button">
<Text>Save</Text>
</Pressable>
</View>
);
}
The package import is a facade over the app's peer dependency. uf does not vendor React Native, does not choose the app's native runtime version, and does not try to mirror every upstream export in a hand-written list. It re-exports the real package so the public surface moves with React Native.
Metro
Metro still owns the bundle, the dev server and Fast Refresh. What uf adds is a Babel transformer that runs Flow, the React Compiler and the StyleX policy before the transformer the project already had, and a helper that wires it in.
Compose the config the project already has. In an Expo project:
// metro.config.js
// @noflow
const { getDefaultConfig } = require("expo/metro-config");
const { withUniflowedMetro } = require("@uniflowed/react-native/metro");
module.exports = withUniflowedMetro(getDefaultConfig(__dirname));
In a React Native CLI project:
// metro.config.js
// @noflow
const { getDefaultConfig, mergeConfig } = require("@react-native/metro-config");
const { withUniflowedMetro } = require("@uniflowed/react-native/metro");
module.exports = withUniflowedMetro(mergeConfig(getDefaultConfig(__dirname), {}));
Both files are CommonJS, and so is the helper. Metro evaluates its config in
plain Node — under expo start, react-native start, Xcode's and Gradle's
bundle phases, and on EAS Build — where none of uf's loader hooks are
installed, so this is the one @uniflowed/* module that is not Flow. A config
written as an ES module can import { withUniflowedMetro } from the same
specifier.
The helper keeps everything the config already says and adds what uf needs:
| Field | What the helper does |
|---|---|
resolver.sourceExts | appends js, jsx, mjs and cjs where they are missing |
resolver.resolverMainFields | appends react-native, browser and main where they are missing |
transformer.babelTransformerPath | points at @uniflowed/react-native/metro-transformer.cjs, and keeps the transformer that was there as the one that runs next |
Metro already understands .ios.js, .android.js and .native.js. The helper
does not reimplement platform suffix resolution; it keeps Metro and uf naming
the same files.
Every real config already names a Babel transformer — getDefaultConfig()
names Expo's in an Expo project and React Native's in a bare one — and that
transformer is not replaced. For a module uf owns, the chain is:
Flow lowering -> React Compiler -> StyleX native objects -> the project's transformer
The project's transformer is what applies babel-preset-expo or React Native's
preset: the module system, platform inlining, and the React Refresh
registrations Fast Refresh needs. All of that stays exactly as it was. A module
uf does not own — anything under node_modules except @uniflowed/* — goes
straight to it.
Recent Metro versions omit the hot flag React Native's Babel transformer still
reads to enable React Refresh. uf restores it for a development bundle, while
honouring an explicit hot: false, so both Expo and React Native CLI register
Flow components for Fast Refresh. A transformer the config names by package is resolved from the
project root while the config loads, so one that is not installed is an error
there rather than halfway through the first bundle, and
UF_METRO_UPSTREAM_TRANSFORMER overrides the choice when a person needs to.
For native Flow components, import stylex from @uniflowed/stylex/native.
Its props preserve the authored React Native style types and have no className.
On native, stylex.create compiles to marked React Native style objects and
stylex.props returns { style: ... }. Numeric dimensions, colors, flexbox,
spacing, borders and text styles share the same source with the web build.
The supported subset is explicit: unsupported properties, pseudo-classes,
media queries, CSS variables, themes and CSS-only units are named in a build
error. Mixing native output and web classes is also an error.
The transformer compiles with the uf binary, found in this order: UF_BINARY,
which uf sets to itself for every process it starts; uf on PATH; and the
installer's directory, UF_BIN_DIR or ~/.local/bin. The last is for the
processes with no shell in front of them — Xcode's bundle phase runs with a
minimal PATH — and when none of the three has uf, the error names all of
them. Metro's cache key includes that binary, so upgrading uf does not leave
Metro serving what the previous build compiled.
Source maps are not composed yet: a stack frame in a Flow module points into
uf's output rather than at the Flow source. The uncomposed source-map gap
test in npm/react-native/react-native.test.js records that boundary
explicitly; a successful bundle does not imply original-source stack locations.
The build manifest records the same answer so a downstream tool can check it:
{
"target": "native",
"targetContract": {
"runtime": "react-native",
"renderer": { "status": "pending" },
"router": {
"kind": "navigator",
"status": "implemented",
"package": "@uniflowed/router/native",
"helper": "createNativeScreenRouter",
"screenManifestHelper": "createNativeScreenManifest"
},
"transform": {
"kind": "metro",
"status": "implemented",
"platform": "native",
"sourceExtensions": ["js", "jsx", "mjs", "cjs"],
"pipeline": ["flow", "react-compiler", "stylex-native-objects", "metro-babel"],
"config": {
"package": "@uniflowed/react-native/metro",
"helper": "withUniflowedMetro",
"transformer": "@uniflowed/react-native/metro-transformer.cjs"
},
"stylex": {
"css": "refused"
}
}
}
}
That manifest shape is there because a route-suffixed build is not the same
thing as a complete native runtime. The renderer row stays pending until uf
can mount the tree on a native host.
Build a native bundle
uf build --target ios and uf build --target android run the project's own
expo export:embed or react-native bundle. --target native builds both.
The entry is package.json#main, or index.js; the Metro config must compose
withUniflowedMetro. No native target reaches Vite.
The output is dist/native/<platform>/main.jsbundle, its source map and assets/.
Metro chooses image density variants and resolves imported fonts. Every emitted
file is listed with its platform and byte size in
.uf/build/meta/uf-build-manifest.json. uf explain build --target ios names the
transform, bundler and owner of each subsequent step. Native compilation, config
plugins, prebuild, signing, submission and updates remain with Expo/EAS, Xcode
and Gradle; this command produces JavaScript and assets, not an IPA or APK.
After uf add and uf update, uf compares installed native dependencies with
expo/bundledNativeModules.json and their React Native peer ranges. A mismatch
names the installed version and the required range; ranges uf cannot compare
are reported explicitly. Expo's expo install --check remains available.
Share a page with the web
A plain $page.js can import Text, View and the other primitives from
@uniflowed/react-native on both targets. Install react-native-web in the app;
uf's Vite plugin resolves react-native to that dependency, using the app's
version. Keep DOM wrappers such as <html> in $layout.web.js, and select
app.rsc: false for a React Native Web application.
The native-safe front doors are brand, cell, core, effect, fetch, form,
graphql, hooks, i18n, immer, query, react, react-native, relay,
state, temporal and validator under @uniflowed/, plus
@uniflowed/router/native and @uniflowed/router/routing. Platform-specific
APIs still need the capability their own documentation names. ui, web,
browser, react-testing, story, vrt, pwa and motion need a browser DOM;
Metro refuses their imports with the supported list before producing a bundle.
Use $page.web.js for those components.
Develop on a device
uf dev --target native is the loop npx expo start is: Metro serves the
bundle, a phone opens it, and every edit arrives as a hot update. uf does not
replace any of it. It runs the project's own dev server — expo start in an
Expo project, react-native start in a React Native CLI one — after checking
the one thing uf's half needs, and prints what a device needs in order to
connect.
uf dev --target nativeuf dev · my-app
engine metro, run by expo start (expo 57.0.22)
target native
mode development
transform uf transform, then node_modules/expo/node_modules/@expo/metro-config/build/babel-transformer.js
metro metro.config.js
device exp://192.168.1.20:8081
› Expo Go, or a development build, on the same network opens this; Expo prints its QR code below
After that the terminal is Expo's: the QR code, i and a for a simulator, r
to reload, j for the debugger. A react-native framework project gets the
same loop from a bare uf dev, because native is its default target.
| Step | Who does it |
|---|---|
| Choose the server | uf: Expo CLI when expo is installed, React Native CLI when @react-native-community/cli is |
| Check the Metro config | uf loads it the way Metro does, and refuses one that does not compose withUniflowedMetro(), printing the config to write |
| Bundle, serve, send hot updates | Metro |
| Manifest, QR code, tunnel, key commands | Expo CLI, or React Native CLI |
| Compile each module | uf's transformer, through UF_BINARY: the uf that started the server |
Everything after -- goes to the server untouched:
uf dev --target native -- --tunnel--port becomes the server's own --port. --host is refused, because each
server spells binding its own way: Expo CLI takes --lan, --localhost or
--tunnel, and React Native CLI takes --host, so pass that after --.
uf explain dev --target native names the server, its version and the Metro
config without starting anything.
Both the Expo and React Native CLI fixtures deliver React Refresh registrations for edited components. uf restores the development hot flag when React Native CLI omits it; see Metro. Mounted state preservation still needs a simulator or device check.
Installing the app on a phone stays where it is: Expo Go, a development build
from expo run:ios or EAS Build, or react-native run-ios.
Native navigation
The browser router owns a URL bar and a History API. A native app owns a
navigator. @uniflowed/router/native keeps the shared half small: resolve a uf
route against the generated table, reject destinations a native bundle cannot
own, and hand the result to the application's navigator.
@uniflowed/router installs beside the React 19.2.3 that Expo SDK 57 and React
Native 0.87 ship. Its peers are react 19.2.3 or newer, with react-dom and
react-server-dom-parcel optional, so a native app installs neither. Only the
router's Server Components entries need React 19.3, and nothing a native bundle
imports reaches them.
// @flow
import {
createNativeScreenManifest,
createNativeScreenRouter,
nativeScreenName,
} from "@uniflowed/router/native";
import type { RouteTable } from "@uniflowed/router/routing";
export function nativeRouter(routeTable: RouteTable) {
const manifest = createNativeScreenManifest(routeTable, {
name(route) {
return route.path === "/users/:id" ? "UserProfile" : nativeScreenName(route.path);
},
});
return createNativeScreenRouter(routeTable, manifest.screens, {
push(screen, state) {
navigation.push(screen, {
...state.params,
href: state.href,
search: state.search,
});
},
replace(screen, state) {
navigation.replace(screen, {
...state.params,
href: state.href,
search: state.search,
});
},
prefetch(screen, state) {
warmScreen(screen, state.params);
},
});
}
The routeTable is the generated native table. The web entry receives its
table from virtual:uf/routes, a module that exists only inside Vite; Metro has
no virtual modules, so uf dev --target native and uf build --target native
write the table for each platform beside router.js:
| File | What Metro gives it to |
|---|---|
router.ios.js | an iOS bundle |
router.android.js | an Android bundle |
router.native.js | any other React Native platform |
Import ./router and Metro's own platform resolution chooses the file:
// @flow
import { route, routeTable } from "./router";
const router = nativeRouter(routeTable);
await router.push(route("/users/:id", { id: "42" }));
Each file names the exact modules the uf scanner chose for its platform, so
$page.ios.js over $page.native.js over $page.js is decided once, by the
same scanner uf build uses, and a test holds that scanner to the one the web
build runs. Each also carries the typed RoutePath and route() for its
platform, plus a layouts export naming the directory every layout belongs
to. While uf dev --target native runs, the files are rewritten when a route
file is added or removed, and Metro sends the change to the device like any
other edit. The web router.js is not touched, so a project with both targets
keeps its web route types.
The helper is not coupled to a particular navigator:
const router = nativeRouter(routeTable);
await router.push("/users/42?tab=posts");
Under the hood it calls the methods you supplied:
const manifest = createNativeScreenManifest(routeTable);
createNativeScreenRouter(routeTable, manifest.screens, {
push(screen, state) {
navigation.push(screen, state.params);
},
replace(screen, state) {
navigation.replace(screen, state.params);
},
});
The screen is the mapped app-owned name. The state object keeps the uf route
facts beside the params:
{
params: { id: "42" },
href: "/users/42?tab=posts",
pathname: "/users/42",
search: "?tab=posts",
route: "/users/:id",
}
createNativeScreenManifest(routeTable) walks the generated native route table,
keeps only routes that have a page in the native bundle, and returns both a
screens map for the router and an ordered entries list for registration:
{
screens: {
"/": "Home",
"/users/:id": "UsersById",
},
entries: [
{ screen: "Home", route: "/", file: "app/$page.native.js" },
{ screen: "UsersById", route: "/users/:id", file: "app/users/[id]/$page.native.js" },
],
}
If the generated names do not match the app's navigator, pass name(route).
Two routes resolving to the same screen name are refused with
NativeNavigationError and code: "duplicate-screen" so the app does not
register a screen table whose last route silently wins.
If a navigator needs the lower-level event instead, use createNativeRouter().
The event is the contract:
{
kind: "push",
href: "/users/42?tab=posts",
pathname: "/users/42",
search: "?tab=posts",
route: "/users/:id",
params: { id: "42" },
}
nativeScreenPayload(event, screens) is still available when an adapter wants
to work at that level. It looks up event.route in the map you provide and
returns { screen, href, pathname, search, route, params }; if the route is
missing, it raises NativeNavigationError with code: "missing-screen" rather
than guessing a screen name. nativeScreenNavigationState(payload) drops the
screen name and returns the state object passed by createNativeScreenRouter().
That is still a contract, not a runtime: the app decides whether UserProfile
means React Navigation, Expo Router, a custom stack or something else.
Four things are refused before they reach your navigator:
| Input | Why it is refused |
|---|---|
https://example.com | External URLs belong to Linking or the platform, not the app route table |
settings | A native route has no current document URL to resolve a relative href against |
/users/42#bio | Native screens do not have document anchors |
/server-only | The native route table has no page module for that path |
prefetch() loads the matched page and layout modules before it calls the
optional navigator prefetch callback. It does not render the screen and it does
not guess what navigation library the app uses.
Testing
@uniflowed/react-native-testing can query a tree that already exists:
// @flow
import { createNativeScreen } from "@uniflowed/react-native-testing";
import { expect, it } from "@uniflowed/test";
it("finds the native button by accessible name", () => {
const screen = createNativeScreen({
type: "Pressable",
props: {
accessibilityLabel: "Save changes",
accessibilityState: { disabled: true },
},
children: [{ type: "Text", props: {}, children: ["Save"] }],
});
expect(
screen.getByRole("button", {
name: "Save changes",
disabled: true,
}).type,
).toBe("Pressable");
expect(screen.getByLabelText("Save changes").type).toBe("Pressable");
});
The guide to testing has the full
query contract: getByText, getByLabelText, getByRole, getByTestId,
within, state filters, accessibilityValue filters and the rules for unknown
options.
If the suite installs the optional react-test-renderer peer from the same
React 19.3 release line declared by @uniflowed/react-native-testing,
render() can mount the element into React Test Renderer's JSON tree and then
expose the same native queries plus rerender, unmount and toJSON:
// @flow
import * as React from "react";
import { render } from "@uniflowed/react-native-testing";
import { expect, it } from "@uniflowed/test";
it("finds a rendered native action", () => {
const screen = render(
React.createElement(
"Pressable",
{
accessibilityLabel: "Save changes",
accessibilityState: { disabled: true },
},
React.createElement("Text", null, "Save"),
),
);
expect(
screen.getByRole("button", {
name: "Save changes",
disabled: true,
}).type,
).toBe("Pressable");
screen.rerender(React.createElement("Text", null, "Saved"));
expect(screen.getByText("Saved").type).toBe("Text");
screen.unmount();
});
That is still only a JSON test adapter. React Test Renderer is deprecated upstream, and it does not become Expo, React Navigation, a native host config, layout, platform modules or a device. A test that needs the real native runtime should still be skipped with a reason:
// @flow
import { it } from "@uniflowed/test";
it.skipBecause(
"renders the settings screen on the native host",
"uf can query React Test Renderer JSON, but this suite needs a native host config.",
);
That is louder than a TODO and safer than a DOM-rendered substitute.
Linting native code
uf lint includes react-native/platform-split, a warning that prefers
platform files over runtime branches:
// Warns.
import { Platform } from "@uniflowed/react-native";
const file = Platform.OS === "ios" ? "haptics-ios" : "haptics-android";
The native route scanner follows the same design. A .ios.js / .android.js
pair is a stronger statement than an if hidden in the module body, because
the bundle, the route manifest, Metro and the linter can all see it without
executing your code.
It is a warning rather than an error. Some branches are genuinely about a single expression, and splitting a file to satisfy a tool would make the code worse. The rule is there for modules whose real shape is platform-specific.
Compared with Expo
Expo is a complete application platform, and uf is not trying to become a second one. uf owns the Flow toolchain, the file-system routes and the orchestration of the loop; Expo owns the app runtime, the native project and the services that build and update it. Where Expo already has the answer, uf runs Expo rather than replacing it.
The development loop and routing
| Expo | uf | |
|---|---|---|
| Start developing | npx expo start: Metro, a QR code, Expo Go or a development build | uf dev --target native runs expo start itself, after checking that Metro routes modules through uf's transformer |
| Fast Refresh | Metro and babel-preset-expo | unchanged: uf's transformer runs first, and babel-preset-expo still registers every component |
| Compile a module | Babel, with babel-preset-expo | Flow component and hook syntax, Flow's lowering and the React Compiler in Rust, then babel-preset-expo — the same transform the web build and uf test run |
| File routes | Expo Router: app/ files and _layout files | the uf scanner chooses $page.ios.js, then $page.native.js, then $page.js, identically in Rust and JavaScript |
| Stacks, tabs and layouts | Expo Router's _layout navigators | @uniflowed/router/native-navigation turns the generated route and layout table into the app's React Navigation navigators |
| Typed routes | generated href types | route() and RoutePath for every target: router.js on the web, router.ios.js, router.android.js and router.native.js on native |
| Deep links | automatic | explicit app.router.nativeLinks claims, generated association files, and createNativeLinking |
| Platform files | .ios.js / .android.js resolution | the same, plus react-native/platform-split, which prefers them to Platform.OS branches |
Native projects, builds and updates
| Expo | uf | |
|---|---|---|
| Native projects | config plugins and expo prebuild | Expo's — uf generates no native project |
| Builds on a device | expo run:ios, expo run:android, development builds | Expo's |
| Cloud builds and submission | EAS Build and EAS Submit | Expo's; install the pinned uf toolchain in the build hook below |
| Over-the-air updates | EAS Update | Expo's |
| Native modules | the Expo SDK, npx expo install | Expo's; uf reports installed dependencies outside the SDK and React Native peer ranges |
HTTPS links into the app
Declare the domains and the exact route patterns the app may open. A web route
is not an app link until it appears in routes:
export default {
app: {
targets: ["web", "react-native"],
router: {
nativeLinks: {
origins: ["https://example.com"],
routes: ["/about", "/users/:id"],
iosAppIds: ["ABCDE12345.com.example.app"],
androidPackage: "com.example.app",
// Replace with the production app-signing certificate fingerprint.
androidSha256: ["AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA"],
},
},
},
};
uf build --target web writes .well-known/apple-app-site-association and
.well-known/assetlinks.json under the output directory and lists both in the
build manifest and terminal summary. A claim with no web, iOS or Android page
fails the build. Missing app identities or fingerprints fail configuration
loading. Catch-all routes are refused; APIs and admin pages are not implicitly
claimed. A conflicting file in public/.well-known is also refused.
The generated native tables carry the same origins and route patterns.
router.push("https://example.com/users/42") resolves the same screen and params
as router.push("/users/42"). Origins are compared after URL canonicalization;
suffix domains, credentials, non-default ports, fragments, invalid percent
encoding, and URLs outside the claimed routes are rejected.
Connect the native runtime's cold and warm deliveries once at application startup, and remove the subscription when its owner unmounts:
import { Linking } from "@uniflowed/react-native";
import { createNativeLinking } from "@uniflowed/router/native";
import { routeTable } from "./router";
const links = createNativeLinking(routeTable, Linking);
const initial = await links.getInitialURL();
if (initial != null) await router.push(initial);
const unsubscribe = links.subscribe((href) => { void router.push(href); });
// Later: unsubscribe();
router above is the app-owned navigator adapter from native navigator adapter.
Both deliveries use the same parser. Duplicate delivery of the same URL within
one second produces one navigation; invalid or unclaimed links produce none.
The OS configuration still belongs to the native project. For Expo, add
ios.associatedDomains: ["applinks:example.com"] and Android intentFilters
with action: "VIEW", autoVerify: true, categories BROWSABLE and DEFAULT,
and HTTPS data entries for example.com restricted to the claimed paths
(for this example, path: "/about" and pathPrefix: "/users/"). Use the Play
App Signing certificate's SHA-256 fingerprint, not the upload certificate.
Development certificates belong to a separate development configuration.
Deploy both files at the root of every declared origin, with HTTPS, HTTP 200,
JSON content type and no redirects. uf start and uf preview serve that
content type, including the extensionless Apple file. A CDN or static host must
preserve it. basePath is refused with nativeLinks, since associations are
always fetched from the origin root. Android 15+ also reads the generated
path restrictions; older Android versions rely on the manifest's intent
filters. See Apple's associated domains
and Android's association rules.
Using both together
In an app create-expo-app made:
- Add
@uniflowed/react-nativeto the dependencies, and auf.config.jswithapp: { framework: "react-native", targets: ["react-native"] }. - Compose
metro.config.jswithwithUniflowedMetro(getDefaultConfig(__dirname)), as in Metro. - Write screens and components in Flow, run
uf dev --target native, and scan Expo's QR code. - Keep installing native modules with
npx expo install, and building and updating with EAS.uf installaccepts the template'sstart,android,iosandwebscripts and says it does not run them. It refuses only install-time hooks such aspostinstall(see Scope).
The Metro and Babel configuration files are plain Node modules. Use // @noflow
for these host configuration files; application source remains type-checked.
This also avoids relying on Expo's TypeScript config re-exports being inferred
as Flow. CI loads the configs with Metro itself.
Installing uf where Expo bundles
Pin the same uf release on developer machines and build workers. For example,
commit scripts/install-uf.sh and invoke it from the Expo
eas-build-post-install script:
#!/bin/sh
set -eu
curl -fsSL https://setup.uniflowed.dev/install.sh -o /tmp/install-uf.sh
UF_VERSION=0.0.0-alpha.41 sh /tmp/install-uf.sh
"$HOME/.local/bin/uf" --version
The transformer also searches ~/.local/bin, so the installation works when
EAS starts a new shell for bundling. A custom installation should pass the
absolute executable through UF_BINARY. On local native builds, for example:
UF_BINARY="$(command -v uf)" npx expo run:android
For Xcode, put export UF_BINARY=/absolute/path/to/uf in
ios/.xcode.env.local after expo prebuild, alongside its NODE_BINARY setting.
Do not commit a developer-specific absolute path. Gradle inherits UF_BINARY
from the environment that starts it. Expo's generated Android project invokes
export:embed; uf's transformer is still composed in the Metro configuration
that command reads.
Choose one owner for file routes. With uf, keep uf's $page files in app/
and register the app's entry yourself. With Expo Router, keep its entry and
_layout conventions, set app.router.enabled: false, and use uf for Flow,
lint and tests. Pointing both routers at the same directory is not supported.
CI installs two consumers outside this repository. The Expo consumer starts
from create-expo-app's SDK 57 blank template and retains its npm scripts.
The checks cover installation, checking, linting and testing, Expo export and
the bundle command used by prebuild, plus real Metro /status, development
bundles, /hot updates and live native route additions/removals for both CLIs.
The HMR probe checks the compiled module and its Refresh boundary; it does not
simulate a mounted device. Simulator/emulator rendering remains separate.
EAS Build/Submit is not run by this Linux job: it has no Expo account token, signing credentials or provisioned native build service. Nor does it assemble an APK or run Xcode. Those remain the project's release jobs, using the same pinned uf installation and Metro configuration.
Device validation
The CI consumer mounts React Native components and real React Navigation stacks and tabs using React Native's official test environment and Test Renderer. The bundle job also compiles the same StyleX component through Vite and Metro. Physical devices and simulator refresh remain separate checks tracked in #1288. A renderer test does not validate native layout, gestures, platform permissions or signing.
The repository checks now cover this target. Testing covers the query surface, and Packages lists the packages that exist today.
Where to go next
Terminal UI is next: React with a terminal for a host.
Mounted state preservation and real OS link delivery are tracked in #1288. The Metro protocol checks above do not substitute for simulator or emulator execution.
File routes with React Navigation
The app owns @react-navigation/native, @react-navigation/native-stack,
@react-navigation/bottom-tabs, react-native-screens and
react-native-safe-area-context. uf supplies the generated route tree and URL
matcher. Create the navigation object once, outside render:
// index.js
import { AppRegistry, Linking } from "react-native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { createNativeNavigation } from "@uniflowed/router/native-navigation";
import { routeTable, layouts } from "./router";
const navigation = createNativeNavigation({
table: routeTable,
layouts,
stack: createNativeStackNavigator(),
tabs: createBottomTabNavigator(),
links: Linking,
});
AppRegistry.registerComponent("MyApp", () => navigation.Root);
Root owns the real NavigationContainer. uf dev, uf build and native
uf test generate the platform-specific router module. For this file tree:
app/$layout.native.js
app/(tabs)/$layout.native.js
app/(tabs)/$page.native.js
app/users/[id]/$page.native.js
The root layout renders <Stack />; the nested layout renders <Tabs />.
Both come from @uniflowed/router/native-navigation and accept the app's
navigator props, including screenOptions. Screens are the direct child
layouts and pages, named layout:/(tabs), / and /users/:id. A custom
navigator uses <Navigator navigator={yourNavigator} /> with the same tree.
// app/(tabs)/$page.native.js
import { View, Text } from "react-native";
import { stylex } from "@uniflowed/stylex/native";
import { Link } from "@uniflowed/router/native-navigation";
import { route } from "../../router";
const styles = stylex.create({ root: { padding: 12, backgroundColor: "#f3f7fb" } });
export component Page() {
return <View {...stylex.props(styles.root)}>
<Link href={route("/users/:id", { id: "42" })}><Text>Open user</Text></Link>
</View>;
}
A user screen reads useParams() and calls useNativeRouter().push, replace,
back or prefetch. route() keeps parameter construction typed. Push and
replace dispatch through the active React Navigation stack; a tab navigator
selects its tab. Prefetch imports the page and its layouts. Claimed cold and
warm HTTPS links use the same matcher and preserve stack history. Declare
app.router.nativeLinks as described above; an unclaimed host is ignored.
The low-level createNativeRouter adapter remains available when an app wants
to own the navigator integration itself. For native component and navigation
tests, use the native test environment.
Calling the BFF from native
Prefer $route.js for native HTTP operations. uf build, uf dev, and
uf prepare generate createClient in router.js when the app has route
handlers. Its RequestPath and RequestArgs describe the handler table, including
required parameters and catch-all arrays. The table imports no server code.
Share this generated module with the native app (or generate it in a shared
web/native project); it need not import the handler implementation.
// app/api/profile/[id]/$route.js — the BFF delegates to the existing backend.
export async function GET(request, { params }) {
return fetch(`https://backend.example/profiles/${encodeURIComponent(params.id)}`, {
headers: { authorization: request.headers.get("authorization") ?? "" },
});
}
// app/profile/$page.native.js
import * as React from "@uniflowed/react";
import { useState } from "@uniflowed/react";
import { View, Text, Pressable } from "react-native";
import { createClient } from "../../router.js";
import { accessToken } from "../identity.js"; // Your existing identity provider/secure storage.
const api = createClient({ origin: "https://app.example", getToken: accessToken });
export component Profile() {
const [name, setName] = useState("");
return <View>
<Pressable onPress={async () => {
const response = await api.request("/api/profile/:id", { method: "GET" }, { id: "me" });
if (!response.ok) throw new Error("Profile request failed");
const profile = await response.json();
setName(profile.name);
}}><Text>Load profile</Text></Pressable>
<Text>{name}</Text>
</View>;
}
Paths and parameters are generated and checked; request bodies and response
schemas remain the upstream API's contract. The client uses standard Fetch,
omits ambient cookies, obtains the current token for each call and refuses
redirects. HTTPS is required except loopback URLs; connecting a device to an
HTTP development server requires allowInsecureDevelopment: true explicitly.
Opting a server action into bearer authentication
Browser actions continue to require their Origin guard. A native app can call an action only when middleware explicitly authorizes its bearer token. Use an established verifier owned by the backend/identity provider:
// app/$middleware.js
import { authorizeNativeAction } from "@uniflowed/server";
import { verifyActionPermission } from "./identity.server.js";
export async function middleware(request) {
if (request.headers.has("uf-native-action")) {
const allowed = await authorizeNativeAction(request, verifyActionPermission);
if (!allowed) return new Response(null, { status: 403 });
}
}
verifyActionPermission(token, actionId) returns true only after checking the
issuer, signature, audience, expiry and permission for that specific action.
The action still checks ownership of its resource. This uses your authentication
system; uf supplies no token issuer, ORM, or migrations.
On native, use createNativeActionClient from @uniflowed/router/http-client
with the same origin and getToken. call(actionId, args, path) uses the existing
bounded action wire format. Obtain the action ID from the current BFF build's
manifest through your application distribution; it changes between builds and
is not an authorization credential. Native bundles do not implicitly import
"use server" modules. Prefer the typed route client for a stable public API.
The native channel refuses Origin, Cookie and browser Fetch Metadata headers; missing Origin alone is never enough. See the security contract for the contract.
Edit this pagedocs/app/guide/react-native/$page.mdx