islands-runtime 0.1.1

The shared WASM runtime for islands.rs: reactive Signal/Scope/effect primitives and idempotent island mounting, emitted as islands_core.{js,wasm}.
Documentation
mod effect;
#[cfg(feature = "nav")]
mod nav;
mod panic;
mod registry;
mod scope;
mod signal;

pub use signal::Signal;

#[cfg(feature = "nav")]
pub use nav::navigate;

use scope::{current_scope, with_current_scope};
use wasm_bindgen::prelude::*;

/// Install the `console_error_panic_hook` so Rust panics appear in the browser
/// console instead of a cryptic "unreachable executed" message.
#[wasm_bindgen]
pub fn init_panic_hook() {
    panic::init_panic_hook();
}

/// Initialize client navigation when the `nav` feature is enabled.
///
/// This runs as the shared core's wasm-bindgen `start`, so it fires when a page
/// awaits `core_init()` during bootstrap — before any page WASM can trigger a
/// nav (the contract's init invariant). [`nav::init`] is idempotent, so a
/// bfcache restore or a repeated load attaches its listeners only once.
#[cfg(feature = "nav")]
#[wasm_bindgen(start)]
pub fn init_nav() {
    nav::init();
}

/// Register a mount function for a named island.
///
/// `mount` is called by `mount_all` with `(element, props_json_str)` for each
/// matching DOM node. It must be a two-argument JS function.
#[wasm_bindgen]
pub fn register_island(name: &str, mount: &js_sys::Function) -> Result<(), JsValue> {
    registry::register_island_fn(name.to_owned(), mount.clone());
    Ok(())
}

/// Walk `[data-island]:not([data-island-mounted])`, create a `Scope` per
/// island, call the registered mount function inside the scope, then mark the
/// element as mounted. Islands that error are logged and skipped; others continue.
#[wasm_bindgen]
pub fn mount_all() -> Result<(), JsValue> {
    let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
    let document = window
        .document()
        .ok_or_else(|| JsValue::from_str("no document"))?;

    let nodes = document
        .query_selector_all("[data-island]:not([data-island-mounted])")?;

    for i in 0..nodes.length() {
        let node = nodes.get(i).ok_or_else(|| JsValue::from_str("missing node"))?;
        let element = registry::node_to_element(node.into())
            .ok_or_else(|| JsValue::from_str("node is not an Element"))?;

        let name = match element.get_attribute("data-island") {
            Some(n) => n,
            None => {
                web_sys::console::warn_1(&JsValue::from_str(
                    "data-island attribute missing on node",
                ));
                continue;
            }
        };

        let mount_fn = match registry::get_island_fn(&name) {
            Some(f) => f,
            None => {
                web_sys::console::warn_1(&JsValue::from_str(&format!(
                    "no island registered: {name}"
                )));
                continue;
            }
        };

        let props_json = element
            .get_attribute("data-island-props")
            .unwrap_or_else(|| "{}".to_owned());

        let scope = scope::Scope::new();
        with_current_scope(&scope, || {
            let result = mount_fn.call2(
                &JsValue::NULL,
                &element.clone().into(),
                &JsValue::from_str(&props_json),
            );
            if let Err(e) = result {
                web_sys::console::error_2(
                    &JsValue::from_str(&format!("island {name} failed:")),
                    &e,
                );
            }
        });

        registry::mark_mounted(&element, scope);
    }

    Ok(())
}

/// Called by the JS side after `$ISLANDS_REPLACE` injects new island markers.
/// Idempotent because `mount_all` only processes `:not([data-island-mounted])`.
#[wasm_bindgen]
pub fn __islands_remount() -> Result<(), JsValue> {
    mount_all()
}

/// Tear down a mounted island: run its `Scope`'s LIFO cleanups exactly once and
/// drop its registry entry so the slot is reclaimed.
///
/// This is the inverse of one `mount_all` iteration and the removal counterpart
/// the registry previously lacked. The client-navigation layer calls it from the
/// morph's `before_node_removed` callback (for an island leaving the page) and
/// from `before_node_morphed` when an island's `data-island-props` changed and it
/// must be re-mounted fresh.
///
/// `SCOPE_REGISTRY` is the sole strong owner of each `Scope` (signals and effects
/// hold only `Weak` references), so removing the entry drops the last `Rc` and
/// runs the scope's cleanups — and runs them only once, because a second call
/// finds no entry and returns early.
///
/// The `data-island-mounted` and `data-island-scope-id` attributes are cleared so
/// the element is indistinguishable from never-mounted markup: a subsequent
/// `mount_all` will re-mount it, and no stale scope id can collide with a future
/// mount. An element that was never mounted (no `data-island-scope-id`) is a
/// no-op.
#[wasm_bindgen]
pub fn unmount_island(element: &web_sys::Element) -> Result<(), JsValue> {
    let scope_id = match registry::lookup_scope_id(element) {
        Some(id) => id,
        None => return Ok(()),
    };

    // Dropping this `Rc` at the end of scope runs the `Scope`'s `Drop` (LIFO
    // cleanups). Bind it so the drop happens after the attributes are cleared,
    // and so the registry removal — not the cleanup body — is the exactly-once
    // gate. A second call to this function finds the entry already gone.
    let removed_scope = registry::remove_scope(scope_id);

    let _ = element.remove_attribute("data-island-mounted");
    let _ = element.remove_attribute("data-island-scope-id");

    drop(removed_scope);
    Ok(())
}

/// Number of scopes the registry currently holds. Exposed for the navigation
/// layer's leak canary: a mount/unmount round-trip must return this count to its
/// baseline (no orphaned `SCOPE_REGISTRY` entries — AC-V14).
#[wasm_bindgen]
pub fn scope_registry_len() -> usize {
    registry::scope_registry_len()
}

/// Attach an event listener on `target` for `event_name`, calling `handler`.
/// A cleanup that removes the listener is registered on the current scope so
/// the listener is removed when the island is unmounted.
#[wasm_bindgen]
pub fn on_event(
    target: &web_sys::EventTarget,
    event_name: &str,
    handler: js_sys::Function,
) -> Result<(), JsValue> {
    let scope = current_scope();
    target.add_event_listener_with_callback(event_name, &handler)?;

    let target_clone = target.clone();
    let event_name_owned = event_name.to_owned();
    let handler_for_cleanup = handler.clone();
    scope.on_cleanup(move || {
        let _ = target_clone
            .remove_event_listener_with_callback(&event_name_owned, &handler_for_cleanup);
    });
    scope.keep_alive(handler);
    Ok(())
}

/// Run `callback` immediately as a reactive effect. Any `Signal::get` calls
/// inside the callback register a subscription; the callback re-runs whenever
/// those signals change.
#[wasm_bindgen]
pub fn effect(callback: js_sys::Function) -> Result<(), JsValue> {
    use std::rc::Rc;
    let scope = current_scope();
    let effect_handle = Rc::new(effect::EffectImpl { callback });
    scope.add_effect(effect_handle.clone());
    effect::run_effect(&effect_handle);
    Ok(())
}

/// Transfer ownership of `value` into the current scope's keeper list so it
/// stays alive for the scope's lifetime. Used by `islands-runtime-bindings` to
/// hand `Closure` objects across the module boundary without `Closure::forget`.
#[wasm_bindgen]
pub fn keep_alive_in_current_scope(value: JsValue) {
    current_scope().keep_alive(value);
}