Vue 3 + Pinia compatible renderer for Perry native desktop apps.
Write standard Vue 3 Composition API components with Pinia stores. Run perry compile. Get a native macOS (and eventually iOS, Android, GTK4, Win32) binary — no Electron, no WebView, no browser engine.
import { createApp } from "vue";
import { createPinia, defineStore } from "pinia";
import { defineComponent, ref, h } from "vue";
const useCounterStore = defineStore("counter", () => {
const count = ref(0);
function inc() {
count.value++;
}
return { count, inc };
});
const App = defineComponent({
setup() {
const store = useCounterStore();
return () =>
h("div", null, [
h("h1", null, "Hello from native!"),
h("p", null, "Count: " + store.count.value),
h("button", { onClick: store.inc }, "+"),
]);
},
});
const app = createApp(App);
app.use(createPinia());
app.mount(null, { title: "My App", width: 480, height: 600 });This is Vue Native for Perry — same Composition API, ref/reactive, Pinia stores, and component model. The target is native desktop, not the web.
Perry is a TypeScript-to-native compiler (TypeScript → HIR → Cranelift JIT → native binary). It has no DOM, no V8, no browser engine. perry-vue bridges Vue 3's component model and Pinia's state management to Perry's imperative widget system.
No changes to your source code are needed. A single block in package.json redirects vue and pinia imports to perry-vue at the compiler's module-resolution stage:
{
"perry": {
"packageAliases": {
"vue": "perry-vue",
"pinia": "perry-vue",
"vue-demi": "perry-vue"
}
}
}Perry intercepts these imports before codegen. Your components never know they're not running against a real Vue runtime.
Perry does not include @vue/compiler-sfc, so <template> blocks cannot be compiled. Instead, write .vue files with a <script lang="ts"> block only, and implement the component as defineComponent + setup() returning a render function using h():
// src/MyComponent.vue
<script lang="ts">
import { defineComponent, ref, h } from "vue"
export default defineComponent({
setup() {
const count = ref(0)
return () => h("div", null, [
h("p", null, "Count: " + count.value),
h("button", { onClick: () => { count.value++ } }, "+"),
])
},
})
</script>h(type, props, children) constructs { type, props, key, children } VNode descriptor objects, compiled to native heap objects via Cranelift. _buildWidget walks the VNode tree and maps each descriptor to a Perry widget handle:
| HTML element | Perry widget | Notes |
|---|---|---|
div (default) |
VStack |
vertical layout |
div + style={{ flexDirection: "row" }} |
HStack |
horizontal layout |
p, h1–h6, label |
Text |
font size set for headings |
button |
Button |
onClick wired |
input[type=text|email] |
TextField |
onUpdate:modelValue |
input[type=password] |
SecureField |
onUpdate:modelValue |
input[type=checkbox] |
Toggle |
onUpdate:modelValue |
input[type=range] |
Slider |
onUpdate:modelValue |
select |
Picker |
onUpdate:modelValue |
img |
Image |
src passed through |
hr |
Divider |
|
ul, ol |
VStack |
|
form, section, article, etc. |
VStack |
semantic → layout |
a |
Button |
href ignored, onClick wired |
video, audio, canvas |
VStack (stub) |
ref() and reactive() are backed by Perry reactive State objects. Writing to .value (or a reactive property):
- Updates the stored value
- Increments a Perry State counter (triggering
onChange) onChangefires_scheduleRerender:- Clears all children from the root widget
- Re-runs the component tree from scratch
- Re-attaches the rebuilt widgets
Pinia stores (Options API) wrap state in a Proxy that calls the same _scheduleRerender on any property mutation.
This is a full-tree rebuild on every state change — simpler than Vue's virtual DOM differ but correct for Phase 1.
# 1. Clone this package next to your project
git clone https://github.com/PerryTS/vue perry-vue
# 2. Create your project
mkdir my-app && cd my-apppackage.json:
{
"name": "my-app",
"main": "src/main.ts",
"perry": {
"packageAliases": {
"vue": "perry-vue",
"pinia": "perry-vue",
"vue-demi": "perry-vue"
}
}
}src/main.ts:
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
const app = createApp(App);
app.use(createPinia());
app.mount(null, { title: "My App", width: 480, height: 600 });# 3. Compile and run
perry compile src/main.ts -o my-app
./my-app| API | Status | Notes |
|---|---|---|
ref() |
✅ | Backed by Perry State |
reactive() |
✅ | Proxy-wrapped, backed by Perry State |
computed() |
Re-evaluates every render; no dep tracking | |
watch() |
Runs callback once immediately; deps ignored | |
watchEffect() |
Runs fn once immediately | |
onMounted() |
✅ | Runs once after first render |
onUnmounted() |
No-op (Phase 1) | |
onUpdated() |
No-op (Phase 1) | |
readonly() |
Pass-through (no enforcement) | |
toRef() / toRefs() |
✅ | |
isRef() / unref() |
✅ | |
provide() / inject() |
Static global map; not reactive |
| Feature | Status |
|---|---|
defineComponent + setup() |
✅ |
| Props | ✅ |
Render function (h()) |
✅ |
Fragment |
✅ |
Conditional rendering (ternary / null) |
✅ |
List rendering (.map()) |
✅ (key ignored) |
KeepAlive, Transition, Teleport |
|
Suspense |
| Feature | Status | Notes |
|---|---|---|
defineStore (Options API) |
✅ | state → Proxy, getters, actions |
defineStore (Setup syntax) |
✅ | ref/computed/function |
createPinia() |
✅ | install no-op; stores are global singletons |
storeToRefs() |
✅ | |
$patch() |
✅ | |
$reset() |
✅ | |
| Store plugin system | ❌ | Not implemented |
- Inline
style={{}}props — supported. A subset of CSS properties map to Perry widget setters. classprop — supported vialoadStylesheet(). A lightweight CSS parser maps class selectors to Perry widget setters.
Supported inline style properties:
flexDirection, display: none, fontSize, color, fontFamily, backgroundColor, opacity, borderRadius, width, height, padding
Vue's v-model desugars to modelValue + onUpdate:modelValue. Since there is no template compiler, write the expansion manually:
h("input", {
type: "text",
modelValue: text.value,
"onUpdate:modelValue": (v: string) => {
text.value = v;
},
});| Vue prop | Status |
|---|---|
onClick |
✅ |
onUpdate:modelValue |
✅ |
onChange |
✅ (fallback when no v-model) |
onMouseenter / onMouseleave |
✅ |
onDblclick |
✅ |
onFocus / onBlur |
❌ No Perry equivalent |
onKeydown / onKeyup |
❌ No Perry equivalent |
<template> blocks, directives (v-if, v-for, v-bind, etc.), and template syntax are not available. All rendering must use h() directly.
Reactivity slot storage uses a single global array indexed by call order. A component used more than once will have all instances sharing the same state slots. A proper per-component-instance store is the key Phase 2 engineering task.
Vuetify, PrimeVue, Naive UI, Quasar, and similar UI frameworks all depend on DOM APIs, CSS, or <template>. None work in Phase 1.
<style scoped>, UnoCSS, WindiCSS, and similar inject CSS into a DOM that doesn't exist.
watch(source, callback) runs the callback once immediately and never re-runs on source changes. Cleanup functions are not called.
Perry does not implement Number.prototype.toString() via the standard method dispatch path. Use String(value) or string interpolation ("" + value) rather than value.toString() in component code.
| NativeScript-Vue | perry-vue (Phase 1) | |
|---|---|---|
| Target | iOS / Android | macOS (Perry native) |
| Styling | NativeScript CSS subset | Inline style={{}} + loadStylesheet() |
| Template compiler | Full Vue template | Not available — use h() |
| Component libraries | NativeScript-* ecosystem | None yet |
| Reconciler | Virtual DOM diff | Full-tree rebuild (Phase 1) |
| Reactivity per instance | ✅ | Global (Phase 1 limitation) |
| Bridge | JS ↔ native | Direct Cranelift → native |
| Pinia support | ✅ (via JS runtime) | ✅ (native Perry State) |
| Status | Production | Phase 1 proof of concept |
Six NaN-boxing fixes were needed in Perry's Cranelift codegen to correctly handle VNode pointers, array children, and Pinia state. See CLAUDE.md for full details.
Core Composition API (ref, reactive, computed, onMounted), h() VNode rendering, element-to-widget mapping, inline styles, class prop via loadStylesheet(), v-model, Pinia (Options + Setup), full-tree re-render.
- Per-component-instance reactivity storage (fiber-like tree)
watchdependency tracking and cleanup- Compile-time Tailwind utility class mapper (200 common classes → Perry setters)
- Proper
<template>SFC compilation (requires Perry SFC loader) perry-router: simple navigation withuseRouter/useRoute
- Yoga layout engine integration (real flexbox)
- CSS cascade engine (static + dynamic)
perry-vue-*native component library ecosystem- Vue DevTools bridge