///|
/// Identity for a component node in the VNode tree.
priv struct ComponentNode {
id : String
}
///|
/// Opaque handle for sending messages into a child component's update loop.
struct Handle[CMsg] {
dispatch : Ref[(CMsg) -> Unit]
}
///|
pub fn[CMsg] Handle::new() -> Handle[CMsg] {
{ dispatch: Ref::new(fn(_) { }) }
}
///|
/// Stores type-erased component state via closure capture.
priv struct ComponentSlot {
current_expanded : Ref[VNode[Unit]]
previous_expanded : Ref[VNode[Unit]]
render : () -> VNode[Unit]
update_subs : () -> Unit
teardown : () -> Unit
}
///|
priv struct ComponentRuntime {
component_counter : Ref[Int]
component_slots : @hashmap.HashMap[String, ComponentSlot]
trigger_render : Ref[() -> Unit]
pending_init_cmds : Array[() -> Unit]
seen_slots : @hashset.HashSet[String]
}
///|
fn new_component_runtime() -> ComponentRuntime {
{
component_counter: Ref::new(0),
component_slots: @hashmap.new(),
trigger_render: Ref::new(fn() { }),
pending_init_cmds: [],
seen_slots: @hashset.new(),
}
}
///|
let active_component_runtime : Ref[ComponentRuntime?] = Ref::new(None)
///|
fn current_component_runtime() -> ComponentRuntime {
guard active_component_runtime.val is Some(runtime) else {
abort("chai: component() requires an active Chai runtime")
}
runtime
}
///|
fn[A] with_component_runtime(runtime : ComponentRuntime, f : () -> A) -> A {
let previous_runtime = active_component_runtime.val
active_component_runtime.val = Some(runtime)
let result = f()
active_component_runtime.val = previous_runtime
result
}
///|
fn[CMsg] disconnected_handle_dispatch() -> (CMsg) -> Unit {
let warned : Ref[Bool] = Ref::new(false)
fn(_msg) {
if not(warned.val) {
warned.val = true
@webapi.Console::warn([
"chai: Cmd::send ignored because component handle is unmounted",
])
}
}
}
///|
/// Create a self-contained component VNode. The component's Model and CMsg
/// types are erased via closure capture; the returned VNode works for any
/// parent Msg type. Pass id~ for stable identity in keyed lists.
pub fn[Model, CMsg, Msg] component(
id? : String,
handle? : Handle[CMsg],
init~ : () -> (Model, Cmd[CMsg]),
update~ : (Model, CMsg) -> (Model, Cmd[CMsg]),
view~ : (Model) -> VNode[CMsg],
subscriptions? : (Model) -> Sub[CMsg] = fn(_) { Sub::none() },
) -> VNode[Msg] {
let runtime = current_component_runtime()
let slot_key = match id {
Some(s) => "id:" + s
None => {
let c = runtime.component_counter.val
runtime.component_counter.val = c + 1
"pos:" + c.to_string()
}
}
runtime.seen_slots.add(slot_key)
match runtime.component_slots.get(slot_key) {
Some(slot) => {
slot.previous_expanded.val = slot.current_expanded.val
slot.current_expanded.val = (slot.render)()
(slot.update_subs)()
}
None => {
let (init_model, init_cmd) = init()
let model_ref : Ref[Model] = Ref::new(init_model)
let msg_queue : Array[CMsg] = []
let is_updating : Ref[Bool] = Ref::new(false)
let comp_dispatch_ref : Ref[(CMsg) -> Unit] = Ref::new(fn(_) { })
let handle_teardown_ref : Ref[() -> Unit] = Ref::new(fn() { })
fn comp_dispatch(cmsg : CMsg) -> Unit {
(comp_dispatch_ref.val)(cmsg)
}
comp_dispatch_ref.val = fn(cmsg) {
msg_queue.push(cmsg)
if is_updating.val {
return
}
is_updating.val = true
drain_messages(msg_queue, model_ref, update, comp_dispatch)
is_updating.val = false
(runtime.trigger_render.val)()
}
fn bind_handle(next_handle : Handle[CMsg]?) -> Unit {
(handle_teardown_ref.val)()
handle_teardown_ref.val = match next_handle {
Some(h) => {
h.dispatch.val = comp_dispatch
fn() { h.dispatch.val = disconnected_handle_dispatch() }
}
None => fn() { }
}
}
let comp_active_subs : @hashmap.HashMap[String, ActiveSub] = @hashmap.new()
let do_update_subs = fn() -> Unit {
update_subs(
subscriptions(model_ref.val),
comp_active_subs,
comp_dispatch,
)
}
let render = fn() -> VNode[Unit] {
with_component_runtime(runtime, fn() {
bind_events(view(model_ref.val), comp_dispatch)
})
}
let expanded = render()
bind_handle(handle)
let slot : ComponentSlot = {
current_expanded: Ref::new(expanded),
previous_expanded: Ref::new(expanded),
render,
update_subs: do_update_subs,
teardown: fn() {
(handle_teardown_ref.val)()
comp_active_subs.each(fn(_, sub) { (sub.cleanup)() })
comp_active_subs.clear()
},
}
runtime.component_slots[slot_key] = slot
let has_init_cmd = init_cmd.tasks.length() > 0
let init_subs = subscriptions(init_model)
let has_subs = init_subs.subs.length() > 0
if has_init_cmd || has_subs {
runtime.pending_init_cmds.push(fn() {
if has_init_cmd {
init_cmd.run(comp_dispatch)
}
if has_subs {
update_subs(init_subs, comp_active_subs, comp_dispatch)
}
})
}
}
}
Component({ id: slot_key })
}
///|
fn drain_pending_init_cmds_for(runtime : ComponentRuntime) -> Unit {
for cmd_fn in runtime.pending_init_cmds {
cmd_fn()
}
runtime.pending_init_cmds.clear()
}
///|
/// Reset all component state for a fresh start.
fn reset_component_state_for(runtime : ComponentRuntime) -> Unit {
runtime.component_slots.each(fn(_, slot) { (slot.teardown)() })
runtime.component_slots.clear()
runtime.component_counter.val = 0
runtime.seen_slots.clear()
}
///|
/// Prepare for a new render pass.
fn begin_render_pass_for(runtime : ComponentRuntime) -> Unit {
runtime.component_counter.val = 0
runtime.seen_slots.clear()
}
///|
/// Remove component slots that were not visited during the last render.
fn remove_unused_component_slots_for(runtime : ComponentRuntime) -> Unit {
runtime.component_slots.retain(fn(k, slot) {
if runtime.seen_slots.contains(k) {
true
} else {
(slot.teardown)()
false
}
})
}