Skip to main content

islands_runtime/
lib.rs

1mod effect;
2#[cfg(feature = "nav")]
3mod nav;
4mod panic;
5mod registry;
6mod scope;
7mod signal;
8
9pub use signal::Signal;
10
11#[cfg(feature = "nav")]
12pub use nav::navigate;
13
14use scope::{current_scope, with_current_scope};
15use wasm_bindgen::prelude::*;
16
17/// Install the `console_error_panic_hook` so Rust panics appear in the browser
18/// console instead of a cryptic "unreachable executed" message.
19#[wasm_bindgen]
20pub fn init_panic_hook() {
21    panic::init_panic_hook();
22}
23
24/// Initialize client navigation when the `nav` feature is enabled.
25///
26/// This runs as the shared core's wasm-bindgen `start`, so it fires when a page
27/// awaits `core_init()` during bootstrap — before any page WASM can trigger a
28/// nav (the contract's init invariant). [`nav::init`] is idempotent, so a
29/// bfcache restore or a repeated load attaches its listeners only once.
30#[cfg(feature = "nav")]
31#[wasm_bindgen(start)]
32pub fn init_nav() {
33    nav::init();
34}
35
36/// Register a mount function for a named island.
37///
38/// `mount` is called by `mount_all` with `(element, props_json_str)` for each
39/// matching DOM node. It must be a two-argument JS function.
40#[wasm_bindgen]
41pub fn register_island(name: &str, mount: &js_sys::Function) -> Result<(), JsValue> {
42    registry::register_island_fn(name.to_owned(), mount.clone());
43    Ok(())
44}
45
46/// Walk `[data-island]:not([data-island-mounted])`, create a `Scope` per
47/// island, call the registered mount function inside the scope, then mark the
48/// element as mounted. Islands that error are logged and skipped; others continue.
49#[wasm_bindgen]
50pub fn mount_all() -> Result<(), JsValue> {
51    let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
52    let document = window
53        .document()
54        .ok_or_else(|| JsValue::from_str("no document"))?;
55
56    let nodes = document
57        .query_selector_all("[data-island]:not([data-island-mounted])")?;
58
59    for i in 0..nodes.length() {
60        let node = nodes.get(i).ok_or_else(|| JsValue::from_str("missing node"))?;
61        let element = registry::node_to_element(node.into())
62            .ok_or_else(|| JsValue::from_str("node is not an Element"))?;
63
64        let name = match element.get_attribute("data-island") {
65            Some(n) => n,
66            None => {
67                web_sys::console::warn_1(&JsValue::from_str(
68                    "data-island attribute missing on node",
69                ));
70                continue;
71            }
72        };
73
74        let mount_fn = match registry::get_island_fn(&name) {
75            Some(f) => f,
76            None => {
77                web_sys::console::warn_1(&JsValue::from_str(&format!(
78                    "no island registered: {name}"
79                )));
80                continue;
81            }
82        };
83
84        let props_json = element
85            .get_attribute("data-island-props")
86            .unwrap_or_else(|| "{}".to_owned());
87
88        let scope = scope::Scope::new();
89        with_current_scope(&scope, || {
90            let result = mount_fn.call2(
91                &JsValue::NULL,
92                &element.clone().into(),
93                &JsValue::from_str(&props_json),
94            );
95            if let Err(e) = result {
96                web_sys::console::error_2(
97                    &JsValue::from_str(&format!("island {name} failed:")),
98                    &e,
99                );
100            }
101        });
102
103        registry::mark_mounted(&element, scope);
104    }
105
106    Ok(())
107}
108
109/// Called by the JS side after `$ISLANDS_REPLACE` injects new island markers.
110/// Idempotent because `mount_all` only processes `:not([data-island-mounted])`.
111#[wasm_bindgen]
112pub fn __islands_remount() -> Result<(), JsValue> {
113    mount_all()
114}
115
116/// Tear down a mounted island: run its `Scope`'s LIFO cleanups exactly once and
117/// drop its registry entry so the slot is reclaimed.
118///
119/// This is the inverse of one `mount_all` iteration and the removal counterpart
120/// the registry previously lacked. The client-navigation layer calls it from the
121/// morph's `before_node_removed` callback (for an island leaving the page) and
122/// from `before_node_morphed` when an island's `data-island-props` changed and it
123/// must be re-mounted fresh.
124///
125/// `SCOPE_REGISTRY` is the sole strong owner of each `Scope` (signals and effects
126/// hold only `Weak` references), so removing the entry drops the last `Rc` and
127/// runs the scope's cleanups — and runs them only once, because a second call
128/// finds no entry and returns early.
129///
130/// The `data-island-mounted` and `data-island-scope-id` attributes are cleared so
131/// the element is indistinguishable from never-mounted markup: a subsequent
132/// `mount_all` will re-mount it, and no stale scope id can collide with a future
133/// mount. An element that was never mounted (no `data-island-scope-id`) is a
134/// no-op.
135#[wasm_bindgen]
136pub fn unmount_island(element: &web_sys::Element) -> Result<(), JsValue> {
137    let scope_id = match registry::lookup_scope_id(element) {
138        Some(id) => id,
139        None => return Ok(()),
140    };
141
142    // Dropping this `Rc` at the end of scope runs the `Scope`'s `Drop` (LIFO
143    // cleanups). Bind it so the drop happens after the attributes are cleared,
144    // and so the registry removal — not the cleanup body — is the exactly-once
145    // gate. A second call to this function finds the entry already gone.
146    let removed_scope = registry::remove_scope(scope_id);
147
148    let _ = element.remove_attribute("data-island-mounted");
149    let _ = element.remove_attribute("data-island-scope-id");
150
151    drop(removed_scope);
152    Ok(())
153}
154
155/// Number of scopes the registry currently holds. Exposed for the navigation
156/// layer's leak canary: a mount/unmount round-trip must return this count to its
157/// baseline (no orphaned `SCOPE_REGISTRY` entries — AC-V14).
158#[wasm_bindgen]
159pub fn scope_registry_len() -> usize {
160    registry::scope_registry_len()
161}
162
163/// Attach an event listener on `target` for `event_name`, calling `handler`.
164/// A cleanup that removes the listener is registered on the current scope so
165/// the listener is removed when the island is unmounted.
166#[wasm_bindgen]
167pub fn on_event(
168    target: &web_sys::EventTarget,
169    event_name: &str,
170    handler: js_sys::Function,
171) -> Result<(), JsValue> {
172    let scope = current_scope();
173    target.add_event_listener_with_callback(event_name, &handler)?;
174
175    let target_clone = target.clone();
176    let event_name_owned = event_name.to_owned();
177    let handler_for_cleanup = handler.clone();
178    scope.on_cleanup(move || {
179        let _ = target_clone
180            .remove_event_listener_with_callback(&event_name_owned, &handler_for_cleanup);
181    });
182    scope.keep_alive(handler);
183    Ok(())
184}
185
186/// Run `callback` immediately as a reactive effect. Any `Signal::get` calls
187/// inside the callback register a subscription; the callback re-runs whenever
188/// those signals change.
189#[wasm_bindgen]
190pub fn effect(callback: js_sys::Function) -> Result<(), JsValue> {
191    use std::rc::Rc;
192    let scope = current_scope();
193    let effect_handle = Rc::new(effect::EffectImpl { callback });
194    scope.add_effect(effect_handle.clone());
195    effect::run_effect(&effect_handle);
196    Ok(())
197}
198
199/// Transfer ownership of `value` into the current scope's keeper list so it
200/// stays alive for the scope's lifetime. Used by `islands-runtime-bindings` to
201/// hand `Closure` objects across the module boundary without `Closure::forget`.
202#[wasm_bindgen]
203pub fn keep_alive_in_current_scope(value: JsValue) {
204    current_scope().keep_alive(value);
205}