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