Fine-grained reactive UI library for Moonbit/JS
Warning: This is a Proof of Concept (PoC)This project is experimental and under active development. APIs may change without notice. Not recommended for production use.
// moon.mod.json
{
"deps": {
"mizchi/luna": "0.1.0"
}
}npm install @luna_ui/luna| Package | js | native | wasm | wasm-gc |
|---|---|---|---|---|
| mizchi/luna (core) | ✅ | ✅ | ✅ | ✅ |
| mizchi/luna/core/render | ✅ | ✅ | ✅ | ✅ |
| mizchi/luna/dom | ✅ | - | - | - |
// Create a signal
let count = @signal.signal(0)
// Create a computed value (memo)
let doubled = @signal.memo(fn() { count.get() * 2 })
// Create an effect that auto-tracks dependencies
let _ = @signal.effect(fn() {
println("Count: " + count.get().to_string())
@signal.on_cleanup(fn() { println("Cleaning up") })
})
// Update triggers the effect
count.set(1) // prints: Count: 1
count.update(fn(n) { n + 1 }) // prints: Count: 2fn counter_component() -> @luna_dom.DomNode {
let count = @signal.signal(0)
let doubled = @signal.memo(fn() { count.get() * 2 })
@luna_dom.div(class="counter", [
@luna_dom.h2([@luna_dom.text("Counter")]),
// Dynamic text updates automatically
@luna_dom.p([@luna_dom.text_dyn(fn() { "Count: " + count.get().to_string() })]),
@luna_dom.p([@luna_dom.text_dyn(fn() { "Doubled: " + doubled().to_string() })]),
@luna_dom.div(class="buttons", [
@luna_dom.button(
on=@luna_dom.events().click(fn(_) { count.update(fn(n) { n - 1 }) }),
[@luna_dom.text("-")],
),
@luna_dom.button(
on=@luna_dom.events().click(fn(_) { count.update(fn(n) { n + 1 }) }),
[@luna_dom.text("+")],
),
@luna_dom.button(
on=@luna_dom.events().click(fn(_) { count.set(0) }),
[@luna_dom.text("Reset")],
),
]),
])
}
fn main {
let doc = @dom.document()
match doc.getElementById("app") {
Some(el) => {
let app = counter_component()
@luna_dom.render(el |> @luna_dom.DomElement::from_dom, app)
}
None => ()
}
}fn conditional_example() -> @luna_dom.DomNode {
let show = @signal.signal(true)
@luna_dom.div([
@luna_dom.button(
on=@luna_dom.events().click(fn(_) { show.update(fn(b) { not(b) }) }),
[@luna_dom.text_dyn(fn() { if show.get() { "Hide" } else { "Show" } })],
),
@luna_dom.show(fn() { show.get() }, fn() {
@luna_dom.div([@luna_dom.text("Conditionally rendered content")])
}),
])
}fn list_example() -> @luna_dom.DomNode {
let items : @signal.Signal[Array[String]] = @signal.signal(["A", "B", "C"])
@luna_dom.ul([
@luna_dom.for_each(fn() { items.get() }, fn(item, index) {
@luna_dom.li([@luna_dom.text(item)])
}),
])
}# Install dependencies
pnpm install
# Run all CI checks (recommended before PR)
just ci
# Type check
just check
# Run all tests
just test
# Run MoonBit tests only
just test-moonbit
# Run browser tests
just test-browser
# Run E2E tests
just test-e2e
# Build
just build
# Format code
just fmt
# Show bundle sizes
just size
# Watch mode
just watchsrc/
├── core/ # Target-independent
│ ├── signal/ # Signal primitives
│ ├── vnode.mbt # VNode definition
│ ├── render/ # HTML string rendering
│ ├── routes/ # Route matching
│ └── serialize/ # State serialization
├── platform/ # Platform-specific
│ ├── dom/ # Browser DOM API
│ │ ├── element/ # Low-level DOM operations (render, diff, reconcile)
│ │ └── router/ # Client-side router
│ ├── js/ # JS-specific
│ │ └── api/ # Public API for JS (@luna_ui/luna)
│ └── server_dom/ # Server-side SSR helpers
├── stella/ # Shard/Island embedding
├── sol/ # SSR framework (CLI + runtime)
├── examples/ # Example applications
└── tests/ # Test fixtures
js/
├── luna/ # npm package (@luna_ui/luna)
└── loader/ # Island hydration loader (@luna_ui/luna-loader)
e2e/ # Playwright E2E tests| Trigger | Description |
|---|---|
| load | Hydrate immediately on page load |
| idle | Hydrate during browser idle time (requestIdleCallback) |
| visible | Hydrate when element becomes visible (IntersectionObserver) |
| media | Hydrate when media query matches |
| none | Manual hydration via __LN_HYDRATE__ |
just sol new myapp| Submodule | Responsibility |
|---|---|
| signal/ | Reactive primitives (Signal, Effect, Computed) |
| render/ | VNode → HTML string rendering |
| routes/ | Type-safe routing |
| serialize/ | State serialization/deserialization |
| vnode.mbt | VNode type definitions |
pub enum Node[E] {
Element(VElement[E]) // HTML element
Text(String) // Static text
DynamicText(() -> String) // Dynamic text
Fragment(Array[Node[E]]) // Fragment
Show(...) // Conditional rendering
For(...) // List rendering
Island(VIsland[E]) // Hydration boundary
WcIsland(VWcIsland[E]) // Web Components Island
Async(VAsync[E]) // Async node
// ...
}let count = @signal.signal(0)
count.get() // 0
count.set(1) // Set value
count.update(fn(n) { n + 1 }) // Update function
// Derived value
let doubled = @signal.computed(fn() { count.get() * 2 })
// Side effects
@signal.effect(fn() {
println(count.get())
})pub enum Attr[E] {
VStatic(String) // Static value
VDynamic(() -> String) // Signal-linked
VHandler(EventHandler[E]) // Event handler
VAction(String) // Declarative action
}pub enum TriggerType {
Load // On page load
Idle // On requestIdleCallback
Visible // On IntersectionObserver detection
Media(String) // On media query match
None // Manual trigger
}pub enum Attr[E] {
VStatic(String)
VDynamic(() -> String)
VHandler(EventHandler[E])
VAction(String)
}pub struct EventHandler[E] {
callback : (E) -> Unit
}pub enum Node[E] {
Element(VElement[E])
Text(String)
DynamicText(() -> String)
Fragment(Array[Node[E]])
Show(() -> Bool, () -> Node[E])
For(() -> Array[Node[E]])
Component(() -> Node[E])
Island(VIsland[E])
WcIsland(VWcIsland[E])
Async(VAsync[E])
ErrorBoundary(VErrorBoundary[E])
Switch(VSwitch[E])
InternalRef(VInternalRef[E])
RawHtml(String)
}#alias(Trigger)
pub(all) enum TriggerType {
Load
Idle
Visible
Media(String)
None
}test {
// Round-trip test: parse then to_string
inspect(@luna.TriggerType::parse("load").to_string(), content="load")
inspect(@luna.TriggerType::parse("idle").to_string(), content="idle")
inspect(@luna.TriggerType::parse("visible").to_string(), content="visible")
inspect(@luna.TriggerType::parse("none").to_string(), content="none")
inspect(
@luna.TriggerType::parse("media:(min-width: 1024px)").to_string(),
content="media:(min-width: 1024px)",
)
}test {
inspect(@luna.Load.to_string(), content="load")
inspect(@luna.Idle.to_string(), content="idle")
inspect(@luna.Visible.to_string(), content="visible")
inspect(
@luna.Media("(max-width: 768px)").to_string(),
content="media:(max-width: 768px)",
)
inspect(@luna.None.to_string(), content="none")
}pub struct VInternalRef[E] {
url : String
state : String
trigger : TriggerType
wc : Bool
styles : String
children : Array[Node[E]]
}pub struct VIsland[E] {
id : String
url : String
state : String
trigger : TriggerType
children : Array[Node[E]]
}pub struct VWcIsland[E] {
name : String
url : String
styles : String
state : String
trigger : TriggerType
children : Array[Node[E]]
}pub enum MyAction { Increment; Decrement } derive(Show)
@luna.h("button", [("onclick", action(Increment))], [...])test {
let count = @signal.signal(0)
let attr : @luna.Attr[Unit] = @luna.attr_dynamic(fn() {
"count-" + count.get().to_string()
})
guard attr is @luna.VDynamic(getter) else { fail("expected VDynamic") }
inspect(getter(), content="count-0")
count.set(5)
inspect(getter(), content="count-5")
}test {
let attr : @luna.Attr[Unit] = @luna.attr_static("my-class")
guard attr is @luna.VStatic(v) else { fail("expected VStatic") }
inspect(v, content="my-class")
}fn[T] batch(f : () -> T) -> Ttest {
let node : @luna.Node[Unit] = @luna.component(fn() {
@luna.h("div", [], [@luna.text("Component")])
})
guard node is @luna.Component(render~) else { fail("expected Component") }
guard render() is @luna.Element(el) else { fail("expected Element") }
inspect(el.tag, content="div")
}fn[T] computed(compute : () -> T) -> (() -> T)fn[T] create_root(f : (() -> Unit) -> T) -> Tfn[T] create_root_with_dispose(f : () -> T) -> (T, () -> Unit)fn effect(fn_ : () -> Unit) -> (() -> Unit)fn effect_once(fn_ : () -> Unit) -> Unitfn effect_when(condition : () -> Bool, fn_ : () -> Unit) -> (() -> Unit)error_boundary(
children=fn() { risky_component() },
fallback=fn(err, reset) {
h("div", [], [
text("Error: " + err.to_string()),
h("button", [("onclick", handler(fn(_) { reset() }))], [text("Retry")])
])
}
)test {
let items = @signal.signal(["a", "b", "c"])
let node : @luna.Node[Unit] = @luna.for_each(fn() {
items.get().map(fn(s) { @luna.text(s) })
})
guard node is @luna.For(render~) else { fail("expected For") }
inspect(render().length(), content="3")
}test {
let node : @luna.Node[Unit] = @luna.fragment([
@luna.text("Hello"),
@luna.text(" "),
@luna.text("World"),
])
guard node is @luna.Fragment(children) else { fail("expected Fragment") }
inspect(children.length(), content="3")
}test {
let node : @luna.Node[Unit] = @luna.h(
"div",
[("class", @luna.attr_static("container"))],
[@luna.text("Hello")],
)
guard node is @luna.Element(el) else { fail("expected Element") }
inspect(el.tag, content="div")
}test {
let h : @luna.EventHandler[Int] = @luna.handler(fn(x) {
let _ = x * 2
})
inspect(h.get_callback()(5), content="()")
}fn[E] internal_ref(url : String, state : String, trigger? : TriggerType, wc? : Bool, styles? : String, children? : Array[Node[E]]) -> Node[E]fn[E] island(id : String, url : String, state : String, children : Array[Node[E]], trigger? : TriggerType) -> Node[E]fn[T] memo(compute : () -> T) -> (() -> T)fn on_cleanup(cleanup : () -> Unit) -> Unitfn on_mount(fn_ : () -> Unit) -> Unittest {
let node : @luna.Node[Unit] = @luna.raw_html("<strong>Bold</strong>")
guard node is @luna.RawHtml(html) else { fail("expected RawHtml") }
inspect(html, content="<strong>Bold</strong>")
}fn register_disposer(disposer : () -> Unit) -> Unitfn register_owner_cleanup(cleanup : () -> Unit) -> Unitfn render_effect(fn_ : () -> Unit) -> (() -> Unit)test {
let visible = @signal.signal(true)
let node : @luna.Node[Unit] = @luna.show(fn() { visible.get() }, fn() {
@luna.text("Visible!")
})
guard node is @luna.Show(condition~, ..) else { fail("expected Show") }
inspect(condition(), content="true")
}switch_(
cases=[
match_case(when=fn() { state.get() == 1 }, render=fn() { text("One") }),
match_case(when=fn() { state.get() == 2 }, render=fn() { text("Two") }),
],
fallback=Some(fn() { text("Other") })
)test {
let node : @luna.Node[Unit] = @luna.text("Hello World")
guard node is @luna.Text(s) else { fail("expected Text") }
inspect(s, content="Hello World")
}test {
let count = @signal.signal(5)
let node : @luna.Node[Unit] = @luna.text_dyn(fn() { count.get().to_string() })
guard node is @luna.DynamicText(getter) else { fail("expected DynamicText") }
inspect(getter(), content="5")
count.set(10)
inspect(getter(), content="10")
}test {
let name = @signal.signal("Alice")
let node : @luna.Node[Unit] = @luna.text_of(name)
guard node is @luna.DynamicText(getter) else { fail("expected DynamicText") }
inspect(getter(), content="Alice")
name.set("Bob")
inspect(getter(), content="Bob")
}fn[T] untracked(f : () -> T) -> Tfn[E] wc_island(name : String, url : String, styles : String, state : String, children : Array[Node[E]], trigger? : TriggerType) -> Node[E]