Skip to content

logue/perry-vue

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

perry-vue

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.


How it works

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.

The import alias trick

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.

SFC files without a template compiler

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() → HIR → native widgets

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, h1h6, 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)

Reactivity and re-renders

ref() and reactive() are backed by Perry reactive State objects. Writing to .value (or a reactive property):

  1. Updates the stored value
  2. Increments a Perry State counter (triggering onChange)
  3. onChange fires _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.


Quick start

# 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-app

package.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

Supported Vue 3 APIs

Composition API

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

Component model

Feature Status
defineComponent + setup()
Props
Render function (h())
Fragment
Conditional rendering (ternary / null)
List rendering (.map()) ✅ (key ignored)
KeepAlive, Transition, Teleport ⚠️ (transparent pass-through)
Suspense ⚠️ (transparent pass-through)

Pinia

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

Styling

  • Inline style={{}} props — supported. A subset of CSS properties map to Perry widget setters.
  • class prop — supported via loadStylesheet(). 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

v-model

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;
  },
});

Events

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

Limitations

No template compiler

<template> blocks, directives (v-if, v-for, v-bind, etc.), and template syntax are not available. All rendering must use h() directly.

Reactive state is global (Phase 1)

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.

No third-party component libraries

Vuetify, PrimeVue, Naive UI, Quasar, and similar UI frameworks all depend on DOM APIs, CSS, or <template>. None work in Phase 1.

No CSS-in-JS or scoped styles

<style scoped>, UnoCSS, WindiCSS, and similar inject CSS into a DOM that doesn't exist.

No watch dep tracking or cleanup

watch(source, callback) runs the callback once immediately and never re-runs on source changes. Cleanup functions are not called.

value.toString() on numbers

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.


Architecture: compared to Vue Native / NativeScript-Vue

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

Compiler changes required (Perry internals)

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.


Roadmap

Phase 1 — done ✅

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.

Phase 2

  • Per-component-instance reactivity storage (fiber-like tree)
  • watch dependency 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 with useRouter / useRoute

Phase 3

  • Yoga layout engine integration (real flexbox)
  • CSS cascade engine (static + dynamic)
  • perry-vue-* native component library ecosystem
  • Vue DevTools bridge

About

@PerryTS + @vuejs binding.

Topics

Resources

License

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors