Sign in

    luna

    Fine-grained reactive UI library for Moonbit/JS

    luna
    ui
    signals
    Download zip
    Author
    Version
    0.2.4
    License
    MIT
    Last updated
    9 months ago
    Downloads
    20K
    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.

    Most internal documents are Japanese. We will translate at v0.2.0

    #Luna UI

    Docs: https://luna.mizchi.workers.dev/

    A reactive UI library written in MoonBit with Island Architecture and Fine-Grained Reactivity inspired by Solid.js and Qwik.

    • Client-side: Compiles to JavaScript for browser DOM rendering and hydration
    • Server-side: Runs on native backend for high-performance SSR
    • JavaScript API: Use from JavaScript/TypeScript via @luna_ui/luna npm package

    #Features

    • Fine-Grained Reactivity - Signal-based reactive primitives (Signal, effect, memo) with automatic dependency tracking
    • Island Architecture - Partial hydration with selective component loading
    • SSR (Server-Side Rendering) - Render to HTML string with streaming support
    • Hydration - Restore interactivity on SSR-rendered content with multiple trigger strategies (load, idle, visible, media)
    • JSX/TSX Support - Use familiar JSX syntax via @luna_ui/luna npm package
    • Multi-target Support - Core signals work on js, native, wasm, wasm-gc

    #Installation

    #MoonBit

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

    #npm (for JSX/TSX)

    npm install @luna_ui/luna

    #Target Support

    Packagejsnativewasmwasm-gc
    mizchi/luna (core)✅✅✅✅
    mizchi/luna/core/render✅✅✅✅
    mizchi/luna/luna/dom✅---

    • Core (mizchi/luna): Signals, VNode, reactive primitives - works on all targets
    • Render (mizchi/luna/core/render): HTML string rendering - works on all targets
    • DOM (mizchi/luna/luna/dom): Browser DOM rendering and hydration - JavaScript only

    #Usage

    #Signals - Reactive Primitives

    // 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: 2

    #DOM Rendering (JavaScript target)

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

    #Conditional Rendering

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

    #List Rendering

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

    #Development

    Requires just command runner.

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

    #Project Structure

    src/ ├── 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

    #Island Architecture

    Luna supports partial hydration through Island Architecture. Components are rendered on the server and selectively hydrated on the client based on triggers:

    TriggerDescription
    loadHydrate immediately on page load
    idleHydrate during browser idle time (requestIdleCallback)
    visibleHydrate when element becomes visible (IntersectionObserver)
    mediaHydrate when media query matches
    noneManual hydration via __LN_HYDRATE__

    #Sol - SSR Framework

    Sol is a built-in SSR framework that integrates with Hono for server-side rendering. Create new projects with:

    just sol new myapp

    #License

    MIT

    AsyncState

    Async state representation - environment independent

    Attr

    Attribute value that can be static or dynamic (signal-based)

    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

    EffectRunner

    Effect wrapper with unique ID for deduplication

    EventHandler

    Event handler type - newtype wrapper for callback function

    MatchCase

    Match case for Switch - pairs a condition with content

    Node

    Virtual DOM node types

    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

    Trigger

    using @mizchi/luna/luna {type mizchi/luna/luna 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, A : Show] action(a : A) ->
    Attr
    [E]

    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) @luna.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] async_(render~ : async () ->
    Node
    [E], fallback~ : () ->
    Node
    [E], on_error? : (Error) ->
    Node
    [E]?) ->
    Node
    [E]

    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] attr_dynamic(getter : () -> String) ->
    Attr
    [E]

    Create a dynamic attribute value

    attr_dynamic_style

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

    Create a dynamic style attribute value

    attr_handler

    Create a handler attribute value

    attr_static

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

    Create a static attribute value

    attr_style

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

    Create a style attribute value (string form, e.g. "color: red; margin: 10px")

    batch

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

    Run a function in a batch - all signal updates are batched

    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

    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

    Combine four signals into one

    component

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

    Create a component VNode

    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)

    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 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 runs immediately and re-runs when dependencies change Returns a dispose function to stop the effect Supports onCleanup() calls inside the effect If inside an owner scope, the effect is automatically registered for disposal

    effect_once

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

    Create a one-time effect that disposes itself after first run

    effect_when

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

    Create an effect that only runs when condition is true

    error_boundary

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

    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

    Flatten a signal of signals

    for_each

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

    Create a list VNode

    fragment

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

    Create a fragment VNode

    get_owner

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

    Create a VNode element

    handler

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

    Create an event handler from a callback

    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] has_dynamic_content(attrs : Array[(String,
    Attr
    [E])]) -> 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] internal_ref(url : String, state : String, trigger? :
    TriggerType
    , wc? : Bool, styles? : String, children? : Array[
    Node
    [E]]) ->
    Node
    [E]

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

    island

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

    Create an island VNode for partial hydration

    match_case

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

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

    on_cleanup

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

    Register a cleanup function to run when the current effect re-runs or disposes Must be called inside an effect

    on_immediate

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

    Subscribe and run immediately with current value

    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 onCleanup 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

    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

    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.

    Example:
    let theme_ctx = create_context("light") provide(theme_ctx, "dark", fn() { // All components here will see "dark" my_component() })

    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)

    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

    show

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

    Create a conditional VNode

    signal

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

    Convenience function to create a signal

    switch_

    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] text(content : String) ->
    Node
    [E]

    Create a text VNode

    text_dyn

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

    Create a dynamic text VNode

    text_of

    Create a text VNode from a signal

    untracked

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

    Run a function without tracking (useful for avoiding circular deps)

    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

    Example:
    let theme = use_context(theme_ctx) // Returns "dark" or default

    watch

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

    Returns a dispose function

    watch_immediate

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

    Watch with immediate execution (runs callback on first value too)

    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] wc_island(name : String, url : String, styles : String, state : String, children : Array[
    Node
    [E]], trigger? :
    TriggerType
    ) ->
    Node
    [E]

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

    Source Files