@hozo/metro
v0.3.0
Published
Metro integration for the Hozo compiler (Native lowering backend).
Maintainers
Readme
@hozo/metro
Metro integration for Hozo's React Native backend. Lowers View/Text/Pressable/Button from @hozo/core into real React Native primitives plus a StyleSheet.create({...}), rewriting each component in place.
The integration is exercised by development and minified production Metro bundles. Physical-device validation is still pending.
Setup
metro.config.js:
const { getDefaultConfig } = require('expo/metro-config') // or '@react-native/metro-config'
const { withHozo } = require('@hozo/metro/config')
const projectRoot = __dirname
const config = getDefaultConfig(projectRoot)
module.exports = withHozo(config, { root: projectRoot })Metro accepts a promised config, so withHozo can resolve the Tailwind theme and generate the dynamic candidate module before bundling begins. It preserves the supplied config and records an existing Babel transformer as its upstream, including Expo's or another tool's transformer.
Importing from @hozo/core costs only what you use
Metro does not tree-shake, so a plain import { Button } from '@hozo/core' would load every pattern, primitive and form control the facade re-exports, used or not. The transformer rewrites named imports from @hozo/core, @hozo/patterns, @hozo/primitives, @hozo/semantics, @hozo/typography, @hozo/behaviors and @hozo/form to the modules that define each name, so an app pays for the components it imports. On the example app this took Hozo's share of the bundle from 591 KB to 436 KB.
This happens only for files Metro transforms through withHozo. A project that resolves Hozo packages through a plain Metro config still gets the whole facade. Web bundlers (Vite, webpack, Rollup) tree-shake the same imports themselves. A type import, a namespace import (import * as Hozo) or a name the rewrite cannot trace stays on the barrel.
TypeScript
Nothing to configure, as long as the project's tsconfig.json extends expo/tsconfig.base or @react-native/typescript-config. Both set:
{ "compilerOptions": { "moduleResolution": "bundler", "customConditions": ["react-native"] } }which is what makes tsc resolve the same entry point Metro does. Every Hozo package publishes two — index.js and index.native.js — behind a react-native export condition, and TypeScript matches that condition only when it is named. A hand-written tsconfig.json that extends neither preset has to say it itself; customConditions requires moduleResolution to be bundler, node16 or nodenext.
Without it, an app is type-checked against the Web declarations: a native-only export such as HozoText from @hozo/core/generated/text reads as missing, and a component whose two halves differ is checked against the wrong half. It still bundles and runs correctly — this is the type layer only — which is why it goes unnoticed.
What the generated candidate module is for
A className Hozo can't read statically — <View className={getVariant()} /> — still has to produce styles. On Web that's free: the class string reaches the DOM and the browser matches it against a generated stylesheet. React Native has no CSS engine, so the string has to be resolved on device.
withHozo scans the project for class-shaped strings and writes node_modules/.hozo/candidates.native.js: a class-name → style-object map plus a resolver bound to it (from @hozo/core/generated/class-resolver, so an app that installed only @hozo/core resolves it under strict pnpm). Files with an unreadable className import it; files without one don't. The same module carries the resolved preflight choice to Native fallback components, so an uncompiled Heading, Separator, Small, Sub, or Sup uses the same browser defaults as compiled code. Metro redirects @hozo/engine/project automatically; no provider or application import is required. The lower-level generateCandidateModule API remains available from @hozo/metro/project.
It runs at config load rather than inside the transformer because Metro transforms in jest-worker subprocesses. Scanning there would mean several processes writing one cache file; the config layer is ordinary main-process code, so there's exactly one writer.
Limitation: the module is generated once, at config load. A class that only becomes a candidate after Metro started — a new string literal in a helper module — needs a Metro restart to appear. (react-native-css documents the same restriction for its transformer.)
Limitation: only unconditional utilities survive this path (the runtime-resolved one). A style object can't express hover:, md:, or pressed:, and making it able to would mean per-component state tracking — a runtime CSS engine, which Hozo deliberately doesn't ship. Those classes are recorded with the reason and warned about when they're actually used, not at build time: appearing in the scan doesn't prove any expression ever produces one. Write them as a static className and they compile to a real style variant with no runtime involved.
dark: and breakpoints need a component function
Written as a static className, dark: and sm:/md:/lg:/xl:/2xl: compile to a React hook from @hozo/engine, spliced as a statement at the top of the enclosing component:
export function Card() {
const __hozoDark = useHozoDark()
return <View style={[styles.hozo_r0_0, __hozoDark && styles.hozo_r0_0_dark]} />
}The hook has to be a statement. Inlining the call into the JSX (style={[a, useHozoDark() && b]}) breaks the rules of hooks as soon as the element sits behind a conditional, so JSX at module scope or in a concise arrow body (() => <View className="dark:..." />) is a build error naming the fix.
@hozo/engine keeps one subscription per app, not one per component, and its snapshot is a coarse value — the breakpoint's name rather than the raw width. A resize that doesn't cross a breakpoint therefore re-renders nothing, and Android's keyboard-driven dimension events never reach it at all, since only width is an input.
Errors vs. warnings
Error-severity diagnostics stop the build. The case that exists for is a Web-only utility (block, grid, h-screen) reaching the Native backend: there's no correct output, so continuing would ship a layout that looks right on Web and is silently wrong on device.
Everything else is a warning printed during the build.
Existing React Native and Expo projects
Hozo compiles a file whose primitives come from react-native, with no
migration to @hozo/core:
import { View, Text } from 'react-native'
export function Card() {
return (
<View className="rounded-xl p-4">
<Text className="font-bold">Hello</Text>
</View>
)
}The compiler always handled this — it matches on the JSX tag name and never
asks where the name came from, which is what proposal §2.1 promises. What
did not was the gate in front of it: every integration tested
code.includes('@hozo/core'), so an Expo project was skipped for not having
been rewritten.
Matching on the tag name is also why the gate cannot simply be widened. A
<View> from a third-party component library has its own props and its own
layout, and lowering it to a React Native View because the tag is spelled
View would replace someone's component with a different one. So a file is
compiled when every primitive-named binding in it comes from a module the
project trusts:
withHozo(config, { sources: ['@hozo/core', 'react-native', './src/ui'] })The default is ['@hozo/core', 'react-native']. A file importing only from
modules outside that list is left alone silently — a project whose own
components are named View is not doing anything wrong. A file mixing the
two is left alone with a warning, because there its author has every
reason to expect lowering.
@expo/ui and other component libraries
@expo/ui exports Text, Button, List, ListItem, ScrollView and
TextInput — every one a native SwiftUI or Jetpack Compose control that
shares nothing with the Hozo primitive of the same name but its spelling.
A compiler matching on tag names alone cannot tell them apart.
So the module list travels into the compiler and resolution happens per tag. This file compiles:
import { View } from 'react-native'
import { Button, Host } from '@expo/ui/swift-ui'
export function Screen() {
return (
<View className="p-4">
<Host><Button label="Save" /></Host>
</View>
)
}The View lowers and gets its styles; the <Host><Button/></Host> is
carried verbatim, the same treatment any component Hozo does not model
already gets. Refusing the whole file would have left the half Hozo
understands uncompiled; accepting it would have turned a SwiftUI button
into a Pressable.
Two Native-side guards had to learn the same distinction: a carried
<Button> is normally a build error, because Hozo's Button is a semantic
primitive and React Native's takes a title and renders no children — but
a carried @expo/ui Button is the correct outcome. And the generated
react-native import skips any name the file already binds to something
else.
Part of Hozo, a Rust-powered universal UI compiler and accessibility-first layer for React Native. Most applications install @hozo/core and one build integration; see Getting started. Source and issues: github.com/iray-tno/hozo.
