Multiplatform app (one source → web + iOS + Android)
The golden rule (read this first)
PMTC compiles your COMPONENT SOURCE — signals, the 15 canonical primitives, a fixed hook set, and a narrow declarative TS subset — to SwiftUI (iOS) and Jetpack Compose (Android). It does NOT transpile npm packages to native. So a multiplatform app is built from: the canonical primitives + reactivity + the ported hooks/services, written in the supported TS subset. Anything outside that compiles for web but silently breaks or drops on native. Build inside the lane and it works first-try; step outside and it won't.
Status: native PMTC is demo-quality (self-rated 66/100). Per-PR validation is
swiftc -parse+kotlinc-against-stubs (syntax-level); full device builds are advisory. Treat the rules below as hard constraints, not suggestions.
Imports — the canonical layer
import { Stack, Inline, Text, Heading, Button, Press, Field, Toggle,
Image, Icon, Link, Scroll, Layer, Spacer, Modal } from '@pyreon/primitives'
import { signal, computed, effect } from '@pyreon/reactivity'Use @pyreon/primitives (the multiplatform layer), NOT @pyreon/elements / @pyreon/ui-components (those are web-only, CSS-in-JS-coupled). The 15 primitives are the entire native UI vocabulary:
| Primitive | Web | iOS | Android | Notes |
|---|---|---|---|---|
<Stack> | flex column | VStack | Column | direction?="column"|"row", gap, align |
<Inline> | flex row | HStack | Row | sugar for <Stack direction="row">. ⚠ does NOT wrap — see gotchas |
<Layer> | abs/overlay | ZStack | Box | stacked children |
<Scroll> | scroll container | ScrollView | verticalScroll Column | |
<Spacer> | flex spacer | Spacer | Spacer(Modifier.weight) | |
<Text> / <Heading> | <span>/<h*> | Text | Text | |
<Button onPress> | <button> | Button | Button | styled CTA |
<Press onPress> | <div role=button> | Button {} | Box(clickable) | unstyled tap target |
<Field value onChangeText> | <input> | TextField | TextField | |
<Toggle> | checkbox | Toggle | Switch | |
<Image> / <Icon> | <img>/svg | Image/SF Symbol | AsyncImage/Icon | |
<Link> | <a> | nav | nav | router-aware |
<Modal open onClose> | overlay | .sheet | Dialog |
One canonical event name everywhere: onPress (not onClick), onChangeText, onSubmit. Tokens-first styling: padding={4}, gap="md". No responsive props on native (v1).
Reactivity — the same on every target
function Counter() {
const count = signal(0)
const doubled = computed(() => count() * 2)
return (
<Stack gap="md">
<Text>{count()}</Text>
<Text>{doubled()}</Text>
<Button onPress={() => count.set(count() + 1)}>+1</Button>
</Stack>
)
}signal → @State/mutableStateOf, computed → computed property/derivedStateOf. Write count.set(v) to update (never count(v)). Multi-statement handlers work: onPress={() => { a.set(1); b.set(2) }}.
The supported TypeScript subset (stay inside this)
PMTC compiles a deliberately narrow, declarative subset. Inside it, native emit is correct:
signal/computed/effect, typed props, the canonical-primitive JSX<For each by>/<Show when>/ ternary /ifarray + string method calls (
.map/.filter/.find/.join/.toUpperCase/…)object-literal
typealiases → structs:type Todo = { id: number; title: string }✅string-literal union aliases → enums:
type Filter = 'all' | 'active' | 'done'✅arithmetic (and
/now yields a float:7 / 2→3.5on all targets)
Outside it, native silently drops or mis-emits (the cliff):
| Don't (silently breaks native) | Do instead |
|---|---|
interface Todo { … } | type Todo = { … } (synthesizes a struct) |
TS enum Color { … } | type Color = 'red' | 'green' (string-literal union) |
class Foo { … } | functions + signals (or defineStore / model()) |
const o = { a: 1 } (bare local object) | a typed signal/store, or a type-aliased shape |
new Map() / new Set() / new Date() | a plain object/array shape; pass time as a prop |
`Hi ${name}` (template literal) | string concat 'Hi ' + name (template-literal native support is partial) |
a?.b?.c (optional chaining) | a && a.b guards |
for / while / switch / try in a body | .map/.filter, <For>, ternary, <Show> |
destructured props function C({ x }) {} | function C(props) { … props.x … } (destructure loses reactivity) |
The framework now warns when you declare a top-level interface/enum/class for native — but most of the other drops are silent. The swiftc -parse gate can't catch type-level corruption, so a wrong build can still pass CI: stay in the subset.
Per-target hooks + services (these DO work on native)
import { useStorage } from '@pyreon/storage' // → @PyreonAppStorage / rememberPyreonStorage
import { useFetch } from '@pyreon/hooks' // → PyreonFetch (URLSession / ktor)
import { useForm } from '@pyreon/form' // → PyreonForm (device-proven)
import { usePermissions } from '@pyreon/permissions'
import { defineStore } from '@pyreon/store' // → PyreonStore
import { useNavigate, useParams, useLoaderData } from '@pyreon/router'Native-ported: reactivity, the 15 primitives, store, machine, state-tree, i18n, form, permissions, storage, the router (nested routes, beforeEnter, per-route loader), and the hooks useFetch / useOnline / useClipboard / useColorScheme. For data, use useFetch — NOT @pyreon/query (TanStack is web-only).
Web-only packages — only via a <WebView> bridge
These can NOT be native-rendered (they're bound to canvas/DOM/vendors): @pyreon/charts (echarts), @pyreon/flow (elkjs+SVG), @pyreon/code (CodeMirror), @pyreon/dnd, @pyreon/document, @pyreon/query, @pyreon/table/virtual, and the whole CSS-in-JS UI stack (elements/styler/rocketstyle/coolgrid/kinetic).
You CAN still use them — host the web component in a <WebView> (a real browser engine) with the bidirectional bridge:
import { WebView } from '@pyreon/primitives'
// data → window.__pyreonData (live, no reload); page calls window.pyreonPostMessage(x) → onMessage
<WebView html={CHART_HTML} data={metrics()} onMessage={(m) => selected.set(m)} />Right for charts/diagrams/editors (self-contained panes you wouldn't reimplement in SwiftUI). Not for core nav/forms/lists — use the native primitives there.
Gotchas (each has bitten a real build)
<Inline>does NOT wrap on Android (it's aRow). 5+ buttons overflow + the last becomes untappable. Keep horizontal groups short, or use<Stack>(vertical) for action lists.No
Doubleconfusion: a fractional literal (signal(9.99)) infersDouble;/always yields a float now. Integer signals stayInt.useLoaderData<T>()reads a routeloader: () => …(zero-param, expression body) auto-fired on navigation.Escape hatches for genuinely-per-platform UI:
<Web>/<NativeIOS>/<NativeAndroid>.
Minimal correct multiplatform app (copy this shape)
import { Stack, Inline, Text, Heading, Button, Field, Toggle } from '@pyreon/primitives'
import { signal, computed } from '@pyreon/reactivity'
type Todo = { id: number; title: string; done: boolean } // type alias, NOT interface
export function App() {
const todos = signal<Todo[]>([])
const draft = signal('')
const remaining = computed(() => todos().filter((t) => !t.done).length)
const add = () => {
if (draft() === '') return
todos.set([...todos(), { id: todos().length, title: draft(), done: false }])
draft.set('')
}
return (
<Stack gap="md" padding={4}>
<Heading>Todos ({remaining()} left)</Heading>
<Inline gap="sm">
<Field value={draft()} onChangeText={(t) => draft.set(t)} />
<Button onPress={add}>Add</Button>
</Inline>
<For each={todos()} by={(t) => t.id}>
{(t) => (
<Inline gap="sm">
<Toggle value={t.done} onChange={(v) => todos.set(todos().map((x) => x.id === t.id ? { ...x, done: v } : x))} />
<Text>{t.title}</Text>
</Inline>
)}
</For>
</Stack>
)
}This compiles to web + iOS + Android from one source: canonical primitives, a type-alias struct, signals, <For> keyed list, .filter/.map, a multi-statement handler — every piece inside the supported subset.
Anti-pattern
Reaching outside the multiplatform lane — the native build silently breaks, often with no warning:
// ❌ DON'T — every line here breaks the native (iOS/Android) build:
import { Card, Button } from '@pyreon/ui-components' // web-only CSS-in-JS — won't render native
import { Chart } from '@pyreon/charts' // echarts canvas — can't be native-rendered
interface Todo { id: number } // PMTC silently DROPS interface → undefined type on device
class TodoStore { items = [] } // class silently dropped too
function Dashboard({ title }: { title: string }) { // destructured prop loses reactivity
const data = { count: 0 } // bare local object literal — dropped on native
const pct = data.count / total // (web `/` is float; native truncates unless coerced — fixed in-compiler)
return <Card>{`Hits: ${title}`}</Card> // template literal partially-supported on native; <Card> is web-only
}// ✅ DO — stay in the lane (or bridge):
import { Stack, Text, Button } from '@pyreon/primitives' // multiplatform UI
import { signal } from '@pyreon/reactivity'
type Todo = { id: number } // type alias → struct on native
function Dashboard(props: { title: string }) { // props.x stays reactive
const count = signal(0) // reactive state, not a bare object
return <Stack><Text>{'Hits: ' + props.title}</Text></Stack>
}
// Need a chart on native? Host it: <WebView html={CHART_HTML} data={metrics()} onMessage={...} />Catch these BEFORE the device build with pyreon doctor --check-native (project scan) or the MCP validate tool (per-snippet) — both flag web-only imports + native-dropped interface/enum/class in files importing @pyreon/primitives.
Related
get_pattern({ name: "routing-setup" })— the router works on native (nested routes,beforeEnter,loader);useNavigate/useParams/useLoaderDataemit per target.get_pattern({ name: "state-management" })—defineStore/@pyreon/storeis native-ported.get_pattern({ name: "data-fetching" })— on native, useuseFetch(NOT@pyreon/query, which is web-only).get_api({ name: "@pyreon/primitives/Stack" })(and the other 14 +WebView) — per-primitive props, per-target mapping, and gotchas.