// DOM Rendering - Fine-grained reactive DOM bindings
//
///|
/// Create a reactive text node that updates when the content changes
pub fn text_node(content : () -> String) -> DomNode {
let doc = @js_dom.document()
let initial = content()
let node = doc.createTextNode(initial)
// RenderEffect for DOM updates (synchronous)
let _ = @resource.render_effect(fn() {
let new_content = content()
node.as_node().setTextContent(new_content)
})
Txt(node)
}
///|
/// Create a reactive text node from a signal
pub fn[T : Show] text_from_signal(sig : @resource.Signal[T]) -> DomNode {
text_node(fn() { sig.get().to_string() })
}
///|
/// Reactive attribute value type
pub(all) enum AttrValue {
Static(String)
Dynamic(() -> String)
Handler((@js.Any) -> Unit)
}
///|
/// SVG namespace constant
pub let svg_ns : String = "http://www.w3.org/2000/svg"
///|
/// MathML namespace constant
pub let mathml_ns : String = "http://www.w3.org/1998/Math/MathML"
///|
/// FFI for createElementNS
extern "js" fn create_element_ns_ffi(
ns : String,
tag : String,
) -> @js_dom.Element =
#| (ns, tag) => document.createElementNS(ns, tag)
///|
/// Create an element with namespace (for SVG, MathML, etc.)
/// Use this for SVG elements since they require the SVG namespace.
///
/// Example:
/// ```moonbit nocheck
/// // Create an SVG rectangle
/// let rect = create_element_ns(
/// svg_ns,
/// "rect",
/// [
/// ("x", Static("10")),
/// ("y", Static("10")),
/// ("width", Static("100")),
/// ("height", Static("50")),
/// ("fill", Static("blue")),
/// ],
/// [],
/// )
/// ```
pub fn create_element_ns(
ns : String,
tag : String,
attrs : Array[(String, AttrValue)],
children : Array[DomNode],
) -> DomNode {
let elem = create_element_ns_ffi(ns, tag)
// Apply attributes
for attr in attrs {
let (name, value) = attr
apply_attribute(elem, name, value)
}
// Append children
for child in children {
elem.as_node().appendChild(child.to_dom()) |> ignore
}
El(DomElement::from_dom(elem))
}
///|
/// Create an element with reactive attributes (returns Node for easy composition)
pub fn create_element(
tag : String,
attrs : Array[(String, AttrValue)],
children : Array[DomNode],
) -> DomNode {
let doc = @js_dom.document()
let elem = doc.createElement(tag)
// Apply attributes
for attr in attrs {
let (name, value) = attr
apply_attribute(elem, name, value)
}
// Append children
for child in children {
elem.as_node().appendChild(child.to_dom()) |> ignore
}
El(DomElement::from_dom(elem))
}
///|
/// Apply a single attribute to an element
fn apply_attribute(
elem : @js_dom.Element,
name : String,
value : AttrValue,
) -> Unit {
match value {
Static(s) =>
if name == "style" {
apply_style_string(elem, s)
} else {
apply_static_attr(elem, name, s)
}
Dynamic(getter) => {
// RenderEffect for DOM updates (synchronous)
let _ = @resource.render_effect(fn() {
let new_value = getter()
if name == "style" {
apply_style_string(elem, new_value)
} else {
apply_static_attr(elem, name, new_value)
}
})
}
Handler(handler) =>
if name == "__ref" {
// Call ref callback with element (not an event listener)
handler(elem.as_any())
} else {
apply_event_handler(elem, name, handler)
}
}
}
///|
/// Apply a static attribute value
fn apply_static_attr(
elem : @js_dom.Element,
name : String,
value : String,
) -> Unit {
if name == "className" || name == "class" {
elem.setClassName(value)
} else if name == "__innerHTML" {
// dangerouslySetInnerHTML - set innerHTML as property
elem.as_any()._set("innerHTML", @js.any(value)) |> ignore
} else if name == "value" {
// Special handling for input value
elem.as_any()._set("value", @js.any(value)) |> ignore
} else if name == "checked" {
elem.as_any()._set("checked", @js.any(value == "true" || value == ""))
|> ignore
} else if name == "disabled" {
if value == "true" || value == "" {
elem.setAttribute("disabled", "")
} else {
elem.removeAttribute("disabled")
}
} else {
elem.setAttribute(name, value)
}
}
///|
/// Apply an event handler
/// Event names are already lowercase (click, input, etc.) - no conversion needed
extern "js" fn apply_event_handler(
elem : @js_dom.Element,
name : String,
handler : (@js.Any) -> Unit,
) -> Unit =
#|(elem, name, handler) => elem.addEventListener(name, handler)
///|
/// Apply style string (e.g. "color: red; margin: 10px")
fn apply_style_string(elem : @js_dom.Element, style : String) -> Unit {
elem.setAttribute("style", style)
}
///|
/// Mount a node to a container
pub fn mount(container : DomElement, n : DomNode) -> Unit {
container.to_dom().as_node().appendChild(n.to_dom()) |> ignore
}
///|
/// Mount to a jsdom container (for tests)
pub fn mount_to(container : @js_dom.Element, n : DomNode) -> Unit {
container.as_node().appendChild(n.to_dom()) |> ignore
}
///|
/// Clear a container
pub fn clear(container : DomElement) -> Unit {
container.to_dom().as_node().setTextContent("")
}
///|
/// Clear a jsdom container (for tests)
pub fn clear_jsdom(container : @js_dom.Element) -> Unit {
container.as_node().setTextContent("")
}
///|
/// Render to a container (clear and mount)
pub fn render(container : DomElement, n : DomNode) -> Unit {
clear(container)
mount(container, n)
}
///|
/// Render to a jsdom container (for tests)
pub fn render_to(container : @js_dom.Element, n : DomNode) -> Unit {
clear_jsdom(container)
mount_to(container, n)
}
///|
/// Helper to collect child nodes from a DomNode.
/// If it's a DocumentFragment, collects all children; otherwise returns single node.
fn collect_child_nodes(node : @js_dom.Node) -> Array[@js_dom.Node] {
// Check if node is a DocumentFragment by nodeType (11 = DocumentFragment)
if node.nodeType() == 11 {
// DocumentFragment: collect all children before they are moved
let children : Array[@js_dom.Node] = []
while node.firstChild() is Some(child) {
children.push(child)
node.removeChild(child) |> ignore
}
children
} else {
[node]
}
}
///|
/// Collect DOM nodes without detaching them.
/// For DocumentFragment, returns its children (before they get moved).
/// For regular nodes, returns the node itself in an array.
fn collect_dom_nodes(node : @js_dom.Node) -> Array[@js_dom.Node] {
if node.nodeType() == 11 {
let children : Array[@js_dom.Node] = []
let child_nodes = node.childNodes()
for i in 0.. Bool, render_fn : () -> DomNode) -> DomNode {
let doc = @js_dom.document()
let placeholder = doc.createComment("show")
// Capture current owner for context inheritance
let captured_owner = @resource.get_owner()
// Track current dispose function (for cleanup when hiding)
let current_dispose : Ref[(() -> Unit)?] = Ref(None)
// Helper to render with a new child owner scope
fn render_with_scope() -> Array[@js_dom.Node] {
@resource.with_parent_owner(captured_owner, fn() {
let (dom, dispose) = @resource.create_root_with_dispose(fn() {
render_fn()
})
current_dispose.val = Some(dispose)
collect_child_nodes(dom.to_dom())
})
}
// Evaluate initial state immediately (untracked to avoid dependency issues)
let initial_show = @resource.untracked(fn() { when() })
let initial_nodes : Array[@js_dom.Node] = if initial_show {
// Use untracked to prevent child signals from being registered
// as dependencies during initial render
@resource.untracked(fn() { render_with_scope() })
} else {
[]
}
// Track all child nodes (may be multiple if fragment was used)
let current_nodes : Ref[Array[@js_dom.Node]] = Ref(initial_nodes)
// Track whether we are in initial render to skip DOM operations
let is_first_run : Ref[Bool] = Ref(true)
// RenderEffect handles subsequent updates only (synchronous DOM updates)
let _ = @resource.render_effect(fn() {
let should_show = when()
let has_nodes = current_nodes.val.length() > 0
// Skip DOM operations on first run since we already rendered initial state above
// But still call when() to register dependency
if is_first_run.val {
is_first_run.val = false
return
}
match (should_show, has_nodes) {
(true, false) =>
// Need to show - create and insert nodes
if placeholder.parentNode() is Some(parent) {
// Use untracked to prevent child signals from being registered
// as dependencies of this Show's effect. This prevents infinite
// loops when nested Show components access signals.
let nodes = @resource.untracked(fn() { render_with_scope() })
for node in nodes {
parent.insertBefore(node, Some(placeholder)) |> ignore
}
current_nodes.val = nodes
}
(false, true) => {
// Need to hide - dispose child owner first (runs cleanups)
match current_dispose.val {
Some(dispose) => {
dispose()
current_dispose.val = None
}
None => ()
}
// Then remove all nodes
for node in current_nodes.val {
if node.parentNode() is Some(parent) {
parent.removeChild(node) |> ignore
}
}
current_nodes.val = ([] : Array[@js_dom.Node])
}
_ => () // No change needed
}
})
// Return fragment containing initial nodes (if any) and placeholder
if initial_nodes.length() > 0 {
let all_nodes : Array[DomNode] = []
for node in initial_nodes {
all_nodes.push(Raw(node))
}
all_nodes.push(Raw(placeholder))
fragment(all_nodes)
} else {
Raw(placeholder)
}
}
///|
/// Loading component - shows fallback during initial load, then maintains
/// stale content during refetch (inspired by SolidJS v2 ).
///
/// State machine:
/// NeverLoaded + pending=true → show fallback
/// NeverLoaded + pending=false → show content, transition to Loaded
/// Loaded + pending=true → keep showing content (stale)
/// Loaded + pending=false → show content (fresh)
pub fn loading(
when~ : () -> Bool,
fallback~ : () -> DomNode,
render_fn : () -> DomNode,
) -> DomNode {
let doc = @js_dom.document()
let placeholder = doc.createComment("loading")
let captured_owner = @resource.get_owner()
// Track dispose functions for fallback and content scopes
let fallback_dispose : Ref[(() -> Unit)?] = Ref(None)
let content_dispose : Ref[(() -> Unit)?] = Ref(None)
// State: has content ever been successfully rendered?
let has_loaded : Ref[Bool] = Ref(false)
// Currently displayed nodes
let current_nodes : Ref[Array[@js_dom.Node]] = Ref([])
// Which mode is currently displayed: true=fallback, false=content
let showing_fallback : Ref[Bool] = Ref(false)
// Helper to render fallback with a new child owner scope
fn render_fallback_with_scope() -> Array[@js_dom.Node] {
@resource.with_parent_owner(captured_owner, fn() {
let (dom, dispose) = @resource.create_root_with_dispose(fn() {
fallback()
})
fallback_dispose.val = Some(dispose)
collect_child_nodes(dom.to_dom())
})
}
// Helper to render content with a new child owner scope
fn render_content_with_scope() -> Array[@js_dom.Node] {
@resource.with_parent_owner(captured_owner, fn() {
let (dom, dispose) = @resource.create_root_with_dispose(fn() {
render_fn()
})
content_dispose.val = Some(dispose)
collect_child_nodes(dom.to_dom())
})
}
// Helper to remove current nodes from DOM
fn remove_current_nodes() -> Unit {
for node in current_nodes.val {
if node.parentNode() is Some(parent) {
parent.removeChild(node) |> ignore
}
}
current_nodes.val = ([] : Array[@js_dom.Node])
}
// Helper to insert nodes before placeholder
fn insert_nodes(nodes : Array[@js_dom.Node]) -> Unit {
if placeholder.parentNode() is Some(parent) {
for node in nodes {
parent.insertBefore(node, Some(placeholder)) |> ignore
}
}
current_nodes.val = nodes
}
// Helper to dispose a scope
fn dispose_scope(scope : Ref[(() -> Unit)?]) -> Unit {
match scope.val {
Some(dispose) => {
dispose()
scope.val = None
}
None => ()
}
}
// Evaluate initial state
let initial_pending = @resource.untracked(fn() { when() })
let initial_nodes : Array[@js_dom.Node] = if initial_pending {
// Show fallback initially
showing_fallback.val = true
@resource.untracked(fn() { render_fallback_with_scope() })
} else {
// Already resolved — show content
has_loaded.val = true
showing_fallback.val = false
@resource.untracked(fn() { render_content_with_scope() })
}
current_nodes.val = initial_nodes
let is_first_run : Ref[Bool] = Ref(true)
// Reactive effect for state transitions
let _ = @resource.render_effect(fn() {
let is_pending = when()
if is_first_run.val {
is_first_run.val = false
return
}
match (is_pending, has_loaded.val, showing_fallback.val) {
// NeverLoaded + pending → already showing fallback, nothing to do
(true, false, true) => ()
// NeverLoaded + resolved → switch from fallback to content
(false, false, _) => {
has_loaded.val = true
dispose_scope(fallback_dispose)
remove_current_nodes()
let nodes = @resource.untracked(fn() { render_content_with_scope() })
insert_nodes(nodes)
showing_fallback.val = false
}
// Loaded + pending → keep stale content (do nothing)
(true, true, false) => ()
// Loaded + resolved → content is already showing, effect will
// re-run render_effect dependencies naturally for content updates
(false, true, false) => ()
// Catch-all: unexpected states
_ => ()
}
})
// Return fragment containing initial nodes and placeholder
if initial_nodes.length() > 0 {
let all_nodes : Array[DomNode] = []
for node in initial_nodes {
all_nodes.push(Raw(node))
}
all_nodes.push(Raw(placeholder))
fragment(all_nodes)
} else {
Raw(placeholder)
}
}
///|
/// List rendering with reference-based DOM reuse (Solid-style)
///
/// Items are tracked by reference equality (JavaScript ===).
/// When items are reordered, their DOM nodes are moved rather than recreated.
/// Each item gets its own owner scope for proper cleanup when removed.
pub fn[T] for_each(
items : () -> Array[T],
render_item : (T, Int) -> DomNode,
) -> DomNode {
let doc = @js_dom.document()
let placeholder = doc.createComment("for")
let entries : Ref[Array[ItemEntry[T]]] = Ref([])
let is_first = Ref(true)
// Capture current owner for context inheritance
let captured_owner = @resource.get_owner()
// Helper to render with a new child owner scope
// Returns both the DOM node and a dispose function
fn render_with_scope(item : T, i : Int) -> (@js_dom.Node, () -> Unit) {
@resource.with_parent_owner(captured_owner, fn() {
let (dom, dispose) = @resource.create_root_with_dispose(fn() {
render_item(item, i)
})
(dom.to_dom(), dispose)
})
}
// Initial render - create entries with proper dispose functions
// Use untracked to prevent dependency tracking during initial render
let fragment = doc.createDocumentFragment()
let initial_items = @resource.untracked(fn() { items() })
for i, item in initial_items {
let (dom, dispose) = @resource.untracked(fn() { render_with_scope(item, i) })
let nodes = collect_dom_nodes(dom)
if nodes.length() == 0 {
let placeholder = doc.createComment("empty")
fragment.as_node().appendChild(placeholder) |> ignore
entries.val.push(
ItemEntry::new_with_nodes_and_dispose(item, [placeholder], dispose),
)
} else {
entries.val.push(
ItemEntry::new_with_nodes_and_dispose(item, nodes, dispose),
)
fragment.as_node().appendChild(dom) |> ignore
}
}
fragment.as_node().appendChild(placeholder) |> ignore
// RenderEffect for DOM updates (synchronous)
let _ = @resource.render_effect(fn() {
let new_items = items()
if is_first.val {
is_first.val = false
return
}
// Use untracked to prevent child signals from being registered
// as dependencies of this For's effect. This prevents infinite
// loops when nested components access signals.
@resource.untracked(fn() {
reconcile_for_each_with_dispose(
entries, placeholder, new_items, render_with_scope,
)
})
})
Raw(fragment.as_node())
}
// =============================================================================
// Index - Index-based list rendering (like Solid.js )
// =============================================================================
///|
/// Entry for index_each - stores node, dispose function, and disposed flag
priv struct IndexEntry {
node : @js_dom.Node
dispose : () -> Unit
disposed : Ref[Bool]
}
///|
fn IndexEntry::new(
node : @js_dom.Node,
dispose : () -> Unit,
disposed : Ref[Bool],
) -> IndexEntry {
{ node, dispose, disposed }
}
///|
/// Index-based list rendering - optimized for lists where items are identified by index
/// Unlike for_each which tracks by reference, index_each tracks by index position.
/// When items change at an index, only that element is re-rendered.
///
/// Best used when:
/// - Items are primitives (strings, numbers)
/// - Item identity is by position, not by value
/// - Lists are frequently mutated by index
///
/// Example:
/// ```
/// index_each(
/// fn() { items.get() },
/// fn(item_getter, index) {
/// div(children=[
/// // item_getter() returns current item at this index
/// text_node(fn() { item_getter().to_string() })
/// ])
/// }
/// )
/// ```
pub fn[T] index_each(
items : () -> Array[T],
render_item : (() -> T, Int) -> DomNode,
) -> DomNode {
let doc = @js_dom.document()
let placeholder = doc.createComment("index")
let entries : Array[IndexEntry] = []
let is_first = Ref(true)
// Capture current owner for context inheritance
let captured_owner = @resource.get_owner()
// Helper to render item with owner scope for proper cleanup
fn render_with_scope(index : Int) -> IndexEntry {
let disposed : Ref[Bool] = Ref(false)
let snapshot = @resource.untracked(fn() { items() })
if index >= snapshot.length() {
// Array changed between scheduling and render. Return inert placeholder.
let stale = doc.createComment("index-stale")
IndexEntry::new(stale, fn() { }, disposed)
} else {
// Cache initial value so stale effects can read safely after shrink.
let initial_value = snapshot[index]
let cached_value : Ref[T?] = Ref(Some(initial_value))
let item_getter = fn() -> T {
let arr = items()
let len = arr.length()
if disposed.val || index >= len {
match cached_value.val {
Some(v) => v
None => initial_value
}
} else {
let value = arr[index]
cached_value.val = Some(value)
value
}
}
let (dom, dispose) = @resource.create_root_with_dispose(fn() {
@resource.with_parent_owner(captured_owner, fn() {
render_item(item_getter, index)
})
})
IndexEntry::new(dom.to_dom(), dispose, disposed)
}
}
// Initial render - use untracked to prevent dependency tracking
let fragment = doc.createDocumentFragment()
let initial_items = @resource.untracked(fn() { items() })
for i, _ in initial_items {
let entry = @resource.untracked(fn() { render_with_scope(i) })
entries.push(entry)
fragment.as_node().appendChild(entry.node) |> ignore
}
fragment.as_node().appendChild(placeholder) |> ignore
// RenderEffect for DOM updates (synchronous)
let _ = @resource.render_effect(fn() {
let new_items = items()
if is_first.val {
is_first.val = false
return
}
let old_len = entries.length()
let new_len = new_items.length()
// Remove excess nodes and dispose their effects
while entries.length() > new_len {
let entry = entries.pop()
match entry {
Some(e) => {
// Mark as disposed FIRST to prevent stale effect execution
e.disposed.val = true
// Then dispose effects
(e.dispose)()
if e.node.parentNode() is Some(par) {
par.removeChild(e.node) |> ignore
}
}
None => ()
}
}
// Add new nodes for indices beyond old length
// Use untracked to prevent child signals from being registered
// as dependencies of this Index's effect
while entries.length() < new_len {
let i = entries.length()
let entry = @resource.untracked(fn() { render_with_scope(i) })
entries.push(entry)
if placeholder.parentNode() is Some(par) {
par.insertBefore(entry.node, Some(placeholder)) |> ignore
}
}
// Note: Existing nodes at indices 0..
///
/// Use cases:
/// - Modals that need to render at body level to avoid z-index issues
/// - Dropdowns/tooltips that need to escape overflow: hidden
/// - Full-screen overlays
///
/// Example:
/// ```
/// portal(
/// target=@js_dom.document().body_(),
/// children=[modal_content()]
/// )
/// ```
///
/// Note: For SSR, portals should be handled separately or use a placeholder.
/// The portal content won't appear in the SSR output at the original location.
pub fn portal(target~ : @js_dom.Element, children~ : Array[DomNode]) -> DomNode {
// Render children into the target container instead of where the portal is placed
for child in children {
target.as_node().appendChild(child.to_dom()) |> ignore
}
// Return a comment placeholder at the original location
let doc = @js_dom.document()
let placeholder = doc.createComment("portal")
Raw(placeholder)
}
///|
/// Portal to body - convenience function for rendering to document.body
pub fn portal_to_body(children : Array[DomNode]) -> DomNode {
let doc = @js_dom.document()
match doc.body() {
Some(body) => portal(target=body.as_element(), children~)
None => {
// Fallback: render inline if body not available
let fragment = doc.createDocumentFragment()
for child in children {
fragment.as_node().appendChild(child.to_dom()) |> ignore
}
Raw(fragment.as_node())
}
}
}
///|
/// Portal to a selector - find element by CSS selector and portal to it
pub fn portal_to(selector : String, children : Array[DomNode]) -> DomNode {
let doc = @js_dom.document()
match doc.querySelector(selector) {
Some(target) => portal(target~, children~)
None => {
// Fallback: render inline if target not found
let fragment = doc.createDocumentFragment()
for child in children {
fragment.as_node().appendChild(child.to_dom()) |> ignore
}
Raw(fragment.as_node())
}
}
}