islands-runtime 0.1.0

The shared WASM runtime for islands.rs: reactive Signal/Scope/effect primitives and idempotent island mounting, emitted as islands_core.{js,wasm}.
Documentation
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;

use wasm_bindgen::JsCast;

use crate::scope::Scope;

thread_local! {
    static ISLAND_REGISTRY: RefCell<HashMap<String, js_sys::Function>> =
        RefCell::new(HashMap::new());
    static SCOPE_REGISTRY: RefCell<HashMap<u32, Rc<Scope>>> =
        RefCell::new(HashMap::new());
    static SCOPE_ID_COUNTER: RefCell<u32> = const { RefCell::new(0) };
}

pub(crate) fn register_island_fn(name: String, mount: js_sys::Function) {
    ISLAND_REGISTRY.with(|r| r.borrow_mut().insert(name, mount));
}

pub(crate) fn get_island_fn(name: &str) -> Option<js_sys::Function> {
    ISLAND_REGISTRY.with(|r| r.borrow().get(name).cloned())
}

pub(crate) fn store_scope(element: &web_sys::Element, scope: Rc<Scope>) {
    let id = element_handle(element);
    store_scope_by_id(id, scope);
}

/// Insert a scope into the registry under an explicit identifier. Split out from
/// `store_scope` so the registry round-trip can be exercised by native tests that
/// have no DOM `Element`.
pub(crate) fn store_scope_by_id(id: u32, scope: Rc<Scope>) {
    SCOPE_REGISTRY.with(|registry| registry.borrow_mut().insert(id, scope));
}

/// Remove a scope from the registry, returning the owning `Rc<Scope>` if one was
/// present. The registry is the sole strong owner of each scope (signals and
/// effects hold only `Weak` references), so dropping the returned `Rc` runs the
/// scope's LIFO cleanups exactly once.
pub(crate) fn remove_scope(id: u32) -> Option<Rc<Scope>> {
    SCOPE_REGISTRY.with(|registry| registry.borrow_mut().remove(&id))
}

/// Number of scopes currently held by the registry. Used by the leak-canary
/// tests (registry size must return to baseline after mount/unmount round-trips)
/// and exposed to the navigation layer for the same introspection.
pub(crate) fn scope_registry_len() -> usize {
    SCOPE_REGISTRY.with(|registry| registry.borrow().len())
}

/// Returns a synthetic u32 handle for the element, minting one via
/// `data-island-scope-id` if needed.
pub(crate) fn element_handle(element: &web_sys::Element) -> u32 {
    if let Some(id) = lookup_scope_id(element) {
        return id;
    }
    let id = SCOPE_ID_COUNTER.with(|counter| {
        let next = *counter.borrow() + 1;
        *counter.borrow_mut() = next;
        next
    });
    let _ = element.set_attribute("data-island-scope-id", &id.to_string());
    id
}

/// Read the element's existing `data-island-scope-id` without minting a new one.
/// `unmount_island` uses this so that an element which was never mounted (and so
/// carries no scope id) is a no-op rather than allocating a fresh handle.
pub(crate) fn lookup_scope_id(element: &web_sys::Element) -> Option<u32> {
    element
        .get_attribute("data-island-scope-id")
        .and_then(|attribute| attribute.parse::<u32>().ok())
}

/// Mark element as mounted and store its scope.
pub(crate) fn mark_mounted(element: &web_sys::Element, scope: Rc<Scope>) {
    let _ = element.set_attribute("data-island-mounted", "true");
    store_scope(element, scope);
}

/// Cast a `web_sys::Node` to `web_sys::Element`.
pub(crate) fn node_to_element(node: wasm_bindgen::JsValue) -> Option<web_sys::Element> {
    node.dyn_into::<web_sys::Element>().ok()
}

#[cfg(test)]
mod tests {
    //! Native (non-wasm) tests for the scope registry's removal path. These
    //! exercise `store_scope_by_id` / `remove_scope` / `scope_registry_len`
    //! directly, with no DOM `Element`, so they run under a plain `cargo test`.
    //! The full `unmount_island(element)` DOM path is covered by the headless
    //! `wasm-bindgen-test` suite in `tests/unmount_island.rs`.
    //!
    //! `SCOPE_REGISTRY` is a `thread_local!`, so each test compares against a
    //! baseline captured at entry rather than an absolute count — robust even if
    //! the harness ever reuses a worker thread across tests.

    use std::cell::Cell;
    use std::rc::Rc;

    use crate::scope::Scope;

    use super::{remove_scope, scope_registry_len, store_scope_by_id};

    #[test]
    fn remove_scope_drops_owner_and_runs_cleanups_exactly_once() {
        let cleanup_run_count = Rc::new(Cell::new(0));

        let scope = Scope::new();
        let counter_for_cleanup = cleanup_run_count.clone();
        scope.on_cleanup(move || {
            counter_for_cleanup.set(counter_for_cleanup.get() + 1);
        });

        let identifier = 9_001;
        store_scope_by_id(identifier, scope);

        // The registry holds a strong reference; the cleanup has not run yet.
        assert_eq!(cleanup_run_count.get(), 0);

        // Removing the entry drops the sole owning `Rc`, which runs the cleanup.
        let removed_scope = remove_scope(identifier);
        assert!(removed_scope.is_some());
        drop(removed_scope);
        assert_eq!(cleanup_run_count.get(), 1);

        // A second removal finds nothing and must not run the cleanup again.
        let removed_again = remove_scope(identifier);
        assert!(removed_again.is_none());
        assert_eq!(cleanup_run_count.get(), 1);
    }

    #[test]
    fn cleanups_run_in_lifo_order_on_drop() {
        let order = Rc::new(std::cell::RefCell::new(Vec::new()));

        let scope = Scope::new();
        for step in 0..3 {
            let order_for_cleanup = order.clone();
            scope.on_cleanup(move || {
                order_for_cleanup.borrow_mut().push(step);
            });
        }

        let identifier = 9_100;
        store_scope_by_id(identifier, scope);
        drop(remove_scope(identifier));

        // Registered 0, 1, 2; LIFO drop must run them 2, 1, 0.
        assert_eq!(*order.borrow(), vec![2, 1, 0]);
    }

    #[test]
    fn registry_length_returns_to_baseline_after_round_trips() {
        let baseline = scope_registry_len();

        let round_trips = 50;
        let first_identifier = 10_000;
        for offset in 0..round_trips {
            let identifier = first_identifier + offset;
            store_scope_by_id(identifier, Scope::new());
            assert_eq!(scope_registry_len(), baseline + 1);
            drop(remove_scope(identifier));
            assert_eq!(scope_registry_len(), baseline);
        }

        // After N mount/unmount round-trips the registry holds no orphans.
        assert_eq!(scope_registry_len(), baseline);
    }
}