luna

Fine-grained reactive UI library for Moonbit/JS

luna
ui
signals
moon add mizchi/luna@0.23.3
Download zip
Author
Version
0.23.3
License
MIT
Last updated
last month
Downloads
17K
README

#Luna

Island Architecture-based UI library implemented in MoonBit.

#Installation

// moon.mod.json { "deps": { "mizchi/luna": "0.7.0" } }

#Packages

PackageDescription
mizchi/luna/js/resourceReactive signals
mizchi/luna/domDOM operations, Hydration
mizchi/luna/core/renderVNode → HTML rendering
mizchi/luna/x/cssCSS Utilities
mizchi/luna/core/routesRoute matching
mizchi/luna/dom/browser_routerBrowser router
mizchi/luna/x/componentsUI components

#Development

just check # Type check just fmt # Format just test-unit # MoonBit tests just test-e2e # E2E tests

#License

MIT

#Luna Core

Core functionality of the Luna UI library. Platform-independent.

#Module Structure

SubmoduleResponsibility
signal/Reactive primitives (Signal, Effect, Computed)
render/VNode → HTML string rendering
routes/Type-safe routing
serialize/State serialization/deserialization
vnode.mbtVNode type definitions

#VNode

Virtual DOM node. Type parameter E represents the event type.

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
// ...
}

#Signal

Reactive value container.

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())
})

#Attr

Attribute values. Supports static/dynamic/event handlers.

pub enum Attr[E] {
VStatic(String) // Static value
VDynamic(() -> String) // Signal-linked
VHandler(EventHandler[E]) // Event handler
VAction(String) // Declarative action
}

#TriggerType

Hydration triggers.

pub enum TriggerType {
Load // On page load
Idle // On requestIdleCallback
Visible // On IntersectionObserver detection
Media(String) // On media query match
None // Manual trigger
}

#References

#
AsyncState

Async state representation - environment independent

#
Attr

Attribute value that can be static or dynamic (signal-based) Type parameter A represents the attribute value type:
  • Web: A = String (HTML attributes are strings)
  • TUI: A = TuiAttrValue (typed values like Dimension, Color)

#
ComponentRef

Component reference for type-safe Island embedding Represents a client-side component that can be embedded in server-rendered HTML
  • url: Path to the client JavaScript (e.g., "/static/counter.js")
  • props: Component props (must be ToJson serializable)
  • wc: Whether this is a Web Components based Island
  • trigger: When to hydrate the component

#
Context

Context type with typed getter The current_getter is managed per-context and tracks the current value chain

#
EventHandler

Event handler type - newtype wrapper for callback function

#
MatchCase

Match case for Switch - pairs a condition with content

#
Node

Virtual DOM node types Type parameter A represents the attribute value type (see Attr[E, A])

#
Owner

Owner - manages lifecycle of reactive computations (Solid.js style)

#
Resource

Resource - Async value container with Signal integration Environment-agnostic: uses callbacks instead of Promise/async directly

#
Signal

Signal type - holds a value and notifies subscribers on change. A reactive primitive that stores a value and automatically tracks dependencies.

#
Trigger

using @mizchi/luna/core {type mizchi/luna/core as Trigger}

Hydration trigger type alias for external use

#
TriggerType

Hydration trigger types - when to hydrate a component

#
VAsync

Virtual Async node for async rendering with error handling Note: async functions implicitly raise Error, no need for raise? annotation

#
VElement

Virtual element node

#
VErrorBoundary

Virtual ErrorBoundary node for catching rendering errors Similar to Solid.js ErrorBoundary - catches errors during:
  • Child component rendering
  • Effects and memos within children Does NOT catch:
  • Event handler errors
  • Async errors outside render cycle

#
VInternalRef

Virtual Internal Reference node for type-safe Island embedding

#
VIsland

Virtual Island node - serializable hydration unit

#
VSwitch

Virtual Switch node - renders first matching case Similar to Solid.js /
  • cases: array of condition-content pairs, evaluated in order
  • fallback: optional content when no case matches

#
VWcIsland

Virtual Web Components Island node - Web Components based hydration unit Uses Declarative Shadow DOM for SSR and DOM Parts for partial updates

#
action

fn[E, V, Act : Show] action(a : Act) ->
Attr
[E, V]

Create an action attribute value - dispatches named action on event Used for declarative event handling with enum types: ("onclick", action(Increment)) // requires derive(Show)

Example:
pub enum MyAction { Increment; Decrement } derive(Show) h("button", [("onclick", action(Increment))], [...])

#
all

fn all(signals : Array[
Signal
[Bool]]) -> (() -> Bool)

Create a signal that is true when all input signals are true.

#
any

fn any(signals : Array[
Signal
[Bool]]) -> (() -> Bool)

Create a signal that is true when any input signal is true.

#
async_

fn[E, A] async_(render~ : async () ->
Node
[E, A], fallback~ : () ->
Node
[E, A], on_error? : (Error) ->
Node
[E, A]?) ->
Node
[E, A]

Create an async VNode with fallback
  • render: async function that produces the content (may raise errors)
  • fallback: shown while loading or on error (if no on_error handler)
  • on_error: optional custom error UI handler

#
attr_dynamic

fn[E, A] attr_dynamic(getter : () -> A) ->
Attr
[E, A]

Create a dynamic attribute value. The value is computed lazily on each render.
test {
let mut count = 0
let attr : Attr[Unit, String] = attr_dynamic(fn() {
"count-" + count.to_string()
})
guard attr is VDynamic(getter) else { fail("expected VDynamic") }
inspect(getter(), content="count-0")
count = 5
inspect(getter(), content="count-5")
}

#
attr_dynamic_style

fn[E] attr_dynamic_style(getter : () -> String) ->
Attr
[E, String]

Create a dynamic style attribute value Note: For Web (A = String), the getter returns a style string

#
attr_handler

fn[E, A] attr_handler(handler :
EventHandler
[E]) ->
Attr
[E, A]

Create a handler attribute value

#
attr_static

fn[E, A] attr_static(value : A) ->
Attr
[E, A]

Create a static attribute value.
test {
let attr : Attr[Unit, String] = attr_static("my-class")
guard attr is VStatic(v) else { fail("expected VStatic") }
inspect(v, content="my-class")
}

#
attr_style

fn[E] attr_style(style : String) ->
Attr
[E, String]

Create a style attribute value (string form, e.g. "color: red; margin: 10px") Note: For Web (A = String), pass the style string directly

#
batch

fn[T] batch(f : () -> T) -> T

Run a function in a batch - all signal updates are batched. Effects only run once after all updates complete.

#
batch_end

fn batch_end() -> Unit

End a batch update - run all pending effects

#
batch_start

fn batch_start() -> Unit

Start a batch update - effects won't run until batch ends

#
combine2

fn[A, B, R] combine2(a :
Signal
[A], b :
Signal
[B], f : (A, B) -> R) -> (() -> R)

Combine two signals into one. The getter recomputes when either signal changes.

#
combine3

fn[A, B, C, R] combine3(a :
Signal
[A], b :
Signal
[B], c :
Signal
[C], f : (A, B, C) -> R) -> (() -> R)

Combine three signals into one

#
combine4

fn[A, B, C, D, R] combine4(a :
Signal
[A], b :
Signal
[B], c :
Signal
[C], d :
Signal
[D], f : (A, B, C, D) -> R) -> (() -> R)

Combine four signals into one

#
component

fn[E, A] component(render : () ->
Node
[E, A]) ->
Node
[E, A]

Create a component VNode. Wraps a render function as a component boundary.
test {
let node : Node[Unit, String] = component(fn() {
h("div", [], [text("Component")])
})
guard node is Component(render~) else { fail("expected Component") }
guard render() is Element(el) else { fail("expected Element") }
inspect(el.tag, content="div")
}

#
component_ref

fn[T] component_ref(url : String, props : T, trigger? :
TriggerType
) ->
ComponentRef
[T]

Create a ComponentRef for a regular Island component

#
computed

fn[T] computed(compute : () -> T) -> (() -> T)

Create a computed signal (alias for memo with Signal-like API). Same as memo, but named to be more familiar for those coming from other reactive frameworks.

#
create_context

fn[T] create_context(default_value : T) ->
Context
[T]

Create a new context with a default value Similar to Solid.js createContext

#
create_root

fn[T] create_root(f : (() -> Unit) -> T) -> T

Create a new reactive root scope. The function receives a dispose callback that cleans up all effects. Returns the result of the function.

#
create_root_with_dispose

fn[T] create_root_with_dispose(f : () -> T) -> (T, () -> Unit)

Create a reactive root and return both result and dispose function

#
deferred

fn[T] deferred() -> (
Resource
[T], (T) -> Unit, (String) -> Unit)

Create a deferred Resource (starts pending, resolve/reject manually)

#
effect

fn effect(fn_ : () -> Unit) -> (() -> Unit)

Create an effect that is deferred until after rendering completes. (Solid.js style createEffect - deferred execution via microtask) Returns a dispose function to stop the effect. Unlike render_effect, this runs asynchronously via microtask queue.

#
effect_once

fn effect_once(fn_ : () -> Unit) -> Unit

Create a one-time effect that disposes itself after first run. Useful for initialization logic that should only run once.

#
effect_when

fn effect_when(condition : () -> Bool, fn_ : () -> Unit) -> (() -> Unit)

Create an effect that only runs when condition is true. Uses render_effect for synchronous execution.

#
error_boundary

fn[E, A] error_boundary(children~ : () ->
Node
[E, A] raise, fallback~ : (Error, () -> Unit) ->
Node
[E, A] raise) ->
Node
[E, A]

Create an error boundary VNode Catches errors during child rendering and displays fallback UI
  • children: lazy function that produces child content (may throw)
  • fallback: function receiving (error, reset) that produces fallback UI
    • error: the caught Error
    • reset: function to retry rendering children

Example:
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")]) ]) } )

#
event_handler

fn event_handler() ->
EventHandler
[Unit]

Create a placeholder event handler for SSR (noop)

#
flatten

fn[T] flatten(outer :
Signal
[
Signal
[T]]) -> (() -> T)

Flatten a signal of signals. Unwraps nested Signal[Signal[T]] to a getter for T.

#
for_each

fn[E, A] for_each(items : () -> Array[
Node
[E, A]]) ->
Node
[E, A]

Create a list VNode. Renders a dynamic list of nodes.
test {
let items = ["a", "b", "c"]
let node : Node[Unit, String] = for_each(fn() { items.map(fn(s) { text(s) }) })
guard node is For(render~) else { fail("expected For") }
inspect(render().length(), content="3")
}

#
fragment

fn[E, A] fragment(children : Array[
Node
[E, A]]) ->
Node
[E, A]

Create a fragment VNode. Groups multiple nodes without a wrapper element.
test {
let node : Node[Unit, String] = fragment([
text("Hello"),
text(" "),
text("World"),
])
guard node is Fragment(children) else { fail("expected Fragment") }
inspect(children.length(), content="3")
}

#
get_owner

fn get_owner() ->
Owner
?

Get the current owner (if any)
fn[E, A] h(tag : String, attrs : Array[(String,
Attr
[E, A])], children : Array[
Node
[E, A]]) ->
Node
[E, A]

Create a VNode element. The main building block for creating virtual DOM elements.
test {
let node : Node[Unit, String] = h(
"div",
[("class", attr_static("container"))],
[text("Hello")],
)
guard node is Element(el) else { fail("expected Element") }
inspect(el.tag, content="div")
}

#
handler

fn[E] handler(f : (E) -> Unit) ->
EventHandler
[E]

Create an event handler from a callback.
test {
let h : EventHandler[Int] = handler(fn(x) { let _ = x * 2 })
inspect(h.get_callback()(5), content="()")
}

#
handler_from_callback

fn handler_from_callback(f : () -> Unit) ->
EventHandler
[Unit]

Create an event handler from a simple callback (ignores event, for SSR compatibility)

#
has_dynamic_content

fn[E, A] has_dynamic_content(attrs : Array[(String,
Attr
[E, A])]) -> Bool

Check if element has dynamic content that needs hydration

#
has_owner

fn has_owner() -> Bool

Check if currently inside an owner scope

#
internal_ref

fn[E, A] internal_ref(url : String, state : String, trigger? :
TriggerType
, wc? : Bool, styles? : String, children? : Array[
Node
[E, A]]) ->
Node
[E, A]

Create an internal reference VNode (for server_dom.island())

#
island

fn[E, A] island(id : String, url : String, state : String, children : Array[
Node
[E, A]], trigger? :
TriggerType
) ->
Node
[E, A]

Create an island VNode for partial hydration

#
match_case

fn[E, A] match_case(when~ : () -> Bool, render~ : () ->
Node
[E, A]) ->
MatchCase
[E, A]

Create a match case for Switch
  • when: condition function, evaluated lazily
  • render: content to render when condition is true

#
memo

fn[T] memo(compute : () -> T) -> (() -> T)

Create a memoized computation. Returns a getter function that caches the result and recomputes only when dependencies change.
fn[T] on(sig :
Signal
[T], callback : (T) -> Unit) -> (() -> Unit)

Explicitly subscribe to a signal with a callback. Returns an unsubscribe function. Unlike effect(), this doesn't auto-track and only listens to this one signal. Callback is NOT called on initial subscription, only on subsequent changes.

#
on_cleanup

fn on_cleanup(cleanup : () -> Unit) -> Unit

Register a cleanup function to run when the current scope disposes. Works in:
  • Effects: cleanup runs before effect re-runs or when effect disposes
  • Components: cleanup runs when component unmounts (Solid.js style - can be called directly in component body)

#
on_immediate

fn[T] on_immediate(sig :
Signal
[T], callback : (T) -> Unit) -> (() -> Unit)

Subscribe and run immediately with current value. Like on, but also invokes the callback with the current value right away.

#
on_mount

fn on_mount(fn_ : () -> Unit) -> Unit

Run a function once after mount (Solid.js style onMount). The function runs without tracking dependencies. Cleanup registered via on_cleanup inside will run when owner is disposed.
fn[T] previous(sig :
Signal
[T]) -> (() -> T?)

Create a signal that holds the previous value of another signal. Returns a getter function for the previous value (None on first read).

#
previous_with_initial

fn[T] previous_with_initial(sig :
Signal
[T], initial : T) -> (() -> T)

Create a signal that holds the previous value with initial value. Unlike previous, this returns a plain T instead of T?.

#
provide

fn[T, R] provide(ctx :
Context
[T], value : T, f : () -> R) -> R

Provide a context value for the current Owner and its descendants The value is associated with the current Owner and will be available to all effects and components created within this scope.

#
raw_html

fn[E, A] raw_html(content : String) ->
Node
[E, A]

Create a raw HTML VNode (content is not escaped). Use with caution: content should be trusted or sanitized.
test {
let node : Node[Unit, String] = raw_html("<strong>Bold</strong>")
guard node is RawHtml(html) else { fail("expected RawHtml") }
inspect(html, content="<strong>Bold</strong>")
}

#
register_disposer

fn register_disposer(disposer : () -> Unit) -> Unit

Register a disposer with the current owner Called internally by effect() to register its dispose function

#
register_owner_cleanup

fn register_owner_cleanup(cleanup : () -> Unit) -> Unit

Register a cleanup with the current owner (alternative to onCleanup in effect)

#
render_effect

fn render_effect(fn_ : () -> Unit) -> (() -> Unit)

Create an effect that runs immediately and re-runs when dependencies change. (Solid.js style createRenderEffect - synchronous execution) Returns a dispose function to stop the effect. Supports on_cleanup() calls inside the effect.

#
resource

fn[T] resource(fetcher : ((T) -> Unit, (String) -> Unit) -> Unit) ->
Resource
[T]

Create a Resource from a callback-based fetcher The fetcher receives (resolve, reject) callbacks This is environment-agnostic - the actual async execution is handled by the caller

#
resource_rejected

fn[T] resource_rejected(error : String) ->
Resource
[T]

Create a Resource with initial error (already rejected)

#
resource_resolved

fn[T] resource_resolved(value : T) ->
Resource
[T]

Create a Resource with initial value (already resolved)

#
run_with_owner

fn[T] run_with_owner(owner :
Owner
, f : () -> T) -> T

Run a function with a specific owner as current

#
select

fn[T] select(items :
Signal
[Array[T]], index :
Signal
[Int]) -> (() -> T?)

Select from an array signal by index signal. Returns None if index is out of bounds.

#
show

fn[E, A] show(when : () -> Bool, child : () ->
Node
[E, A]) ->
Node
[E, A]

Create a conditional VNode. Only renders the child when the condition is true.
test {
let visible = true
let node : Node[Unit, String] = show(fn() { visible }, fn() {
text("Visible!")
})
guard node is Show(condition~, ..) else { fail("expected Show") }
inspect(condition(), content="true")
}

#
signal

fn[T] signal(initial : T) ->
Signal
[T]

Convenience function to create a signal. Shorthand for Signal::new.

#
switch_

fn[E, A] switch_(cases~ : Array[
MatchCase
[E, A]], fallback? : () ->
Node
[E, A]?) ->
Node
[E, A]

Create a switch VNode - renders first matching case Similar to Solid.js /

Example:
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") }) )

#
text

fn[E, A] text(content : String) ->
Node
[E, A]

Create a text VNode.
test {
let node : Node[Unit, String] = text("Hello World")
guard node is Text(s) else { fail("expected Text") }
inspect(s, content="Hello World")
}

#
text_dyn

fn[E, A] text_dyn(content : () -> String) ->
Node
[E, A]

Create a dynamic text VNode. The content is computed lazily on each render.
test {
let mut count = 5
let node : Node[Unit, String] = text_dyn(fn() { count.to_string() })
guard node is DynamicText(getter) else { fail("expected DynamicText") }
inspect(getter(), content="5")
count = 10
inspect(getter(), content="10")
}

#
text_of

fn[E, A, T : Show] text_of(sig :
Signal
[T]) ->
Node
[E, A]

Create a text VNode from a signal. Shorthand for text_dyn(fn() { sig.get().to_string() }).

#
untracked

fn[T] untracked(f : () -> T) -> T

Run a function without tracking (useful for avoiding circular deps). Signal reads inside this function won't create subscriptions.

#
use_context

fn[T] use_context(ctx :
Context
[T]) -> T

Use a context value - returns the current provided value or default Walks up the Owner chain to find the nearest provided value. Similar to Solid.js useContext

#
watch

fn[T : Eq] watch(source : () -> T, callback : (T, T) -> Unit) -> (() -> Unit)

Watch a computed expression and run callback when it changes. Returns a dispose function. The callback receives (newValue, oldValue).

#
watch_immediate

fn[T : Eq] watch_immediate(source : () -> T, callback : (T, T?) -> Unit) -> (() -> Unit)

Watch with immediate execution (runs callback on first value too). The callback receives (newValue, oldValue?) where oldValue is None on first run.

#
wc_component_ref

fn[T] wc_component_ref(url : String, props : T, trigger? :
TriggerType
) ->
ComponentRef
[T]

Create a ComponentRef for a Web Components Island

#
wc_island

fn[E, A] wc_island(name : String, url : String, styles : String, state : String, children : Array[
Node
[E, A]], trigger? :
TriggerType
) ->
Node
[E, A]

Create a Web Components island VNode for partial hydration Uses Declarative Shadow DOM for SSR

Source Files