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}