respo

A tiny virtual DOM library

virtual-dom
moon add tiye/respo@0.3.4
Download zip
Author
Version
0.3.4
License
Apache-2.0
Last updated
3 months ago
Downloads
2K
README

#Respo in MoonBit

A tiny virtual DOM library ported from Respo.rs.

Core ideas:

  • Immutable data by design - Store and component states must be immutable. Updates create new values using record update syntax ({ ..old, field: new_value }). Use Ref[T] for mutable references at the application level
  • Hot reload friendly for better dev experience
  • Embraces types and autocompletion
  • States are stored in a single atom, which is a tree structure with a concept like cursor
  • Uni-directional data flow, dispatching is only allowed when user events occur
  • Effects are for DOM updates, rather than dispatching actions
  • Physical equality optimization - Since data is immutable, unchanged references are skipped during re-rendering

This project is in beta stage. APIs and structure are relatively stable.

#Immutable Data Requirements

Respo requires all state to be immutable. This is not a style preference — it is required for correctness of the render loop.

#How the render loop works

Every animation frame, Respo checks whether the store has changed before re-running the renderer:

raf_loop: if physical_equal(store.val, prev_store) → skip (same heap object) if store.val == prev_store → skip (structurally equal) else → diff vdom, patch DOM

Both checks are only reliable when the store is genuinely immutable (no mut struct fields).

#The mut field pitfall

If Store has mut fields and update() mutates them in place, then { ..store.val } creates a new struct header, but both store.val and prev_store share the same field values via the mutation. The == check returns true and the DOM is never updated — even though the data changed.

// ❌ WRONG — mut field, in-place mutation
struct Store {
mut count : Int // mut field!
} derive(Eq)

fn Store::update(self : Store, op : ActionOp) -> ActionOp? {
self.count 1 // mutates in place — prev_store sees this too
None
}

// caller (broken pattern):
ignore(app.store.val.update(op))
app.store.val = { ..app.store.val } // shallow copy, but fields already mutated
// Result: store.val == prev_store → render skipped → DOM frozen

// ✅ CORRECT — no mut, update returns new store
struct Store {
count : Int
} derive(Eq)

fn Store::update(self : Store, op : ActionOp) -> (Store, ActionOp?) {
match op {
Increment => ({ ..self, count: self.count + 1 }, None)
}
}

// caller (correct pattern):
let (new_store, maybe_op) = app.store.val.update(op)
app.store.val = new_store
// Result: physical_equal fails (new struct) → diff runs → DOM updated

#MoonBit record update syntax

MoonBit's { ..self, field: new_value } creates a new struct with one field replaced. Use it freely for nested updates:

{ ..self, ui: { ..self.ui, username: value } }

#Debugging render skips

Enable Respo's built-in debug logging to diagnose unexpected render skips:

@respo.set_debug_mode(true) // add near app startup, remove after debugging

Console output:

  • [Respo Debug] Render: skipped - store reference unchangedphysical_equal short-circuit (expected after no-op dispatches)
  • [Respo Debug] Render: skipped - store logically equal== short-circuit; if unexpected, check for mut fields in Store
  • [Respo Debug] Render: starting render cycle — diff is running
  • [Respo Debug] Render: N DOM changes — patches applied to DOM

#Usage

moon add tiye/respo

To use Respo, start with the boilerplate at https://github.com/Respo/respo-moonbit-workflow

#License

Apache-2.0

#
ObscureState

type ObscureState

MoonBit does not allow trait object with Selfs, use ffi js value to bypass.

#
RespoApp

pub(all) struct RespoApp[Model] {
store :
Ref
[Model]
storage_key : String
mount_target :
Node

}

get basic App structure

#
RespoApp::backup_model_beforeunload

fn[Model : ToJson] RespoApp::backup_model_beforeunload(self : RespoApp[Model]) -> Unit

backup store to local storage before unload

#
RespoApp::render_loop

fn[Model : Eq, ActionOp, G] RespoApp::render_loop(self : RespoApp[Model], renderer : () ->
RespoNode
[ActionOp, G] raise
RespoCommonError
, dispatch_action : (ActionOp) -> Unit raise
RespoCommonError
) -> Unit

#
RespoStatesTree

pub(all) struct RespoStatesTree {
backup : Json?
cursor :
Vector
[String]
branches :
HashMap
[String, RespoStatesTree]
// private fields
}

Respo maintains states in a tree structure, where the keys are strings, each child component "picks" a key to attach its own state to the tree, and it dispatches events to global store to update the state.

This is an immutable data structure - all updates return new trees.

#
RespoStatesTree::cast_branch

Cast the data in the branch to the specified type. If data is not present but backup exists, restore from backup.

#
RespoStatesTree::local_pair

local state in component could be None according to the tree structure Returns (state, cursor)
let (state, cursor) = states.local_pair();

#
RespoStatesTree::path

#
RespoStatesTree::pick

fn RespoStatesTree::pick(self : RespoStatesTree, name : String) -> RespoStatesTree

Pick a branch from the tree by name, returns a new tree representing that branch.

#
RespoStatesTree::set_in

Immutably update the tree at the specified cursor path. Returns a new tree with the update applied.

#
RespoUpdateState

pub(all) struct RespoUpdateState {
cursor : Array[String]
data : ObscureState?
backup : Json?
}

framework defined action for updating states branch

#
StateRef

pub struct StateRef[T] {
cell :
Ref
[T]
}

StateRef wraps a mutable Ref while serializing to an empty JSON object. Use it for ephemeral fields that should not affect persisted snapshots.
impl Default for StateRef[T]
impl Eq for StateRef[T]
impl Hash for StateRef[T]
impl Show for StateRef[T]
impl ToJson for StateRef[T]

#
StateRef::borrow

fn[T] StateRef::borrow(self : StateRef[T]) ->
Ref
[T]

Returns the underlying Ref so callers can mutate the payload directly.

#
StateRef::get

fn[T] StateRef::get(self : StateRef[T]) -> T

Get the current value (read-only)

#
StateRef::new

fn[T] StateRef::new(value : T) -> StateRef[T]

#
StateRef::set

fn[T] StateRef::set(self : StateRef[T], value : T) -> Unit

Set a new value

#
StateRef::update

fn[T] StateRef::update(self : StateRef[T], f : (T) -> T) -> Unit

Update the value using a function

#
code_fonts

let code_fonts : String

#
collect_global_handlers

Collect all global event handlers from the virtual DOM tree Returns an array of handler functions with their coordinates

#
debug_event

fn debug_event(event_type : String, details : String) -> Unit

Log an event (only when debug mode is enabled)

#
debug_log

fn debug_log(message : String) -> Unit

Log a debug message (only when debug mode is enabled)

#
debug_render

fn debug_render(message : String) -> Unit

Log a render cycle (only when debug mode is enabled)

#
debug_state_change

fn[T : Show] debug_state_change(label : String, old_state : T, new_state : T) -> Unit

Log a state change (only when debug mode is enabled)

#
debug_store_update

fn[T : Show] debug_store_update(action : T) -> Unit

Log store update (only when debug mode is enabled)

#
default_fonts

let default_fonts : String

#
dispatch_global_event

fn[T, G] dispatch_global_event(event : G, tree :
RespoNode
[T, G], dispatch :
DispatchFn
[T]) -> Unit raise
RespoCommonError

Dispatch a global event to all registered handlers in the tree

#
fancy_fonts

let fancy_fonts : String

#
is_debug_mode

fn is_debug_mode() -> Bool

Check if debug mode is enabled

#
mark_need_rerender

fn mark_need_rerender() -> Unit

Signal that the store has changed and a re-render should be scheduled.

Call this after assigning app.store.val = new_store to trigger the next animation-frame render cycle.

#
memo_once1

fn[K : Eq, V] memo_once1(f : (K) -> V) -> ((K) -> V)

Memoize a function with a single cache slot, taking one argument.

#
memo_once2

fn[K1 : Eq, K2 : Eq, V] memo_once2(f : (K1, K2) -> V) -> ((K1, K2) -> V)

Memoize a function with a single cache slot, taking two arguments.

#
memo_once3

fn[K1 : Eq, K2 : Eq, K3 : Eq, V] memo_once3(f : (K1, K2, K3) -> V) -> ((K1, K2, K3) -> V)

Memoize a function with a single cache slot, taking three arguments.

#
memo_once4

fn[K1 : Eq, K2 : Eq, K3 : Eq, K4 : Eq, V] memo_once4(f : (K1, K2, K3, K4) -> V) -> ((K1, K2, K3, K4) -> V)

Memoize a function with a single cache slot, taking four arguments.

#
memo_once5

fn[K1 : Eq, K2 : Eq, K3 : Eq, K4 : Eq, K5 : Eq, V] memo_once5(f : (K1, K2, K3, K4, K5) -> V) -> ((K1, K2, K3, K4, K5) -> V)

Memoize a function with a single cache slot, taking five arguments.

#
memoize1

fn[K : Hash + Eq, V] memoize1(f : (K) -> V) -> ((K) -> V)

#
memoize2

fn[K1 : Hash + Eq, K2 : Hash + Eq, V] memoize2(f : (K1, K2) -> V) -> ((K1, K2) -> V)

#
memoize3

fn[K1 : Hash + Eq, K2 : Hash + Eq, K3 : Hash + Eq, V] memoize3(f : (K1, K2, K3) -> V) -> ((K1, K2, K3) -> V)

#
memoize4

fn[K1 : Hash + Eq, K2 : Hash + Eq, K3 : Hash + Eq, K4 : Hash + Eq, V] memoize4(f : (K1, K2, K3, K4) -> V) -> ((K1, K2, K3, K4) -> V)

#
memoize5

fn[K1 : Hash + Eq, K2 : Hash + Eq, K3 : Hash + Eq, K4 : Hash + Eq, K5 : Hash + Eq, V] memoize5(f : (K1, K2, K3, K4, K5) -> V) -> ((K1, K2, K3, K4, K5) -> V)

#
normal_fonts

let normal_fonts : String

#
preset

let preset : String

common CSS resets for Respo pages

#
render_node

fn[T, U : Eq, G] render_node(mount_target :
Node
, store :
Ref
[U], renderer : () ->
RespoNode
[T, G] raise
RespoCommonError
, dispatch_action : (T) -> Unit raise
RespoCommonError
, _interval : Float?) -> Unit raise
RespoCommonError

Mount and start the render loop for a Respo application.

Immutability contract

store must hold an immutable value type (no mut struct fields). The render loop uses two optimizations to avoid unnecessary work:

  1. Physical equality (physical_equal): if store.val is the exact same heap object as in the previous frame, the render is skipped entirely.
  2. Logical equality (==): if store.val != prev_store but they compare equal, the render is also skipped.

⚠️ mut field pitfall: if your Store struct has mut fields and update() mutates them in place, then { ..store.val } only creates a shallow copy — but prev_store already sees the mutated values through the shared struct reference. Both checks then return true and the DOM is never updated.

Correct pattern: update() must return a brand-new Store value.

// WRONG — mut field + in-place mutation
struct Store { mut count : Int } derive(Eq)
fn Store::update(self : Store, op : Op) -> Op? {
self.count 1 // mutates in place — prev_store sees this too!
None
}

// CORRECT — no mut, update returns new store
struct Store { count : Int } derive(Eq)
fn Store::update(self : Store, op : Op) -> (Store, Op?) {
({ ..self, count: self.count + 1 }, None)
}
// caller:
let (new_store, maybe_op) = app.store.val.update(op)
app.store.val = new_store

#
set_debug_mode

fn set_debug_mode(enabled : Bool) -> Unit

Enable or disable debug mode for Respo framework When enabled, logs state changes, events, and render cycles

#
show_obscure_state

fn show_obscure_state(msg : String, v : ObscureState) -> Unit

#
try_load_storage

fn[Model :
FromJson
+ Default] try_load_storage(key : String) -> Model

#
ui_button

let ui_button : String

#
ui_button_danger

let ui_button_danger : String

#
ui_button_primary

let ui_button_primary : String

#
ui_center

let ui_center : String

layout items in column and center them with flexbox demos https://ui.respo-mvc.org/#/layouts.html

#
ui_column

let ui_column : String

layout items in column and center them with flexbox demos https://ui.respo-mvc.org/#/layouts.html

#
ui_column_dispersive

let ui_column_dispersive : String

layout items in column with flexbox, space around demos https://ui.respo-mvc.org/#/layouts.html

#
ui_column_evenly

let ui_column_evenly : String

layout items in column with flexbox, space evenly demos https://ui.respo-mvc.org/#/layouts.html

#
ui_column_parted

let ui_column_parted : String

layout items in column with flexbox, space between demos https://ui.respo-mvc.org/#/layouts.html

#
ui_expand

let ui_expand : String

expand item with flex:1

#
ui_font_code

let ui_font_code : String

monospace font for code, Source Code Pro, Menlo, Ubuntu Mono, Consolas

#
ui_font_fancy

let ui_font_fancy : String

fancy font for title, Josefin Sans, Helvetica neue, Arial refers to https://fonts.google.com/specimen/Josefin+Sans or https://cdn.tiye.me/favored-fonts/main-fonts.css

#
ui_font_normal

let ui_font_normal : String

normal font for text, Hind, Helvatica, Arial refers to https://fonts.google.com/specimen/Hind or https://cdn.tiye.me/favored-fonts/main-fonts.css

#
ui_fullscreen

let ui_fullscreen : String

full page with absolute position

#
ui_global

let ui_global : String

#
ui_input

let ui_input : String

let ui_link : String

#
ui_row

let ui_row : String

layout items in row with flexbox, items are stretched demos https://ui.respo-mvc.org/#/layouts.html

#
ui_row_center

let ui_row_center : String

layout items in row with flexbox, center them demos https://ui.respo-mvc.org/#/layouts.html

#
ui_row_dispersive

let ui_row_dispersive : String

layout items in row with flexbox, space around demos https://ui.respo-mvc.org/#/layouts.html

#
ui_row_evenly

let ui_row_evenly : String

layout items in row with flexbox, space evenly demos https://ui.respo-mvc.org/#/layouts.html

#
ui_row_middle

let ui_row_middle : String

layout items in row with flexbox, space between demos https://ui.respo-mvc.org/#/layouts.html

#
ui_row_parted

let ui_row_parted : String

layout items in row with flexbox, space between demos https://ui.respo-mvc.org/#/layouts.html

#
ui_textarea

let ui_textarea : String