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
//! Island-lifecycle wiring for the morph (contract steps 4-6).
//!
//! Builds the [`MorphOptions`] whose callbacks bind idiomorph's lifecycle to the
//! runtime's island registry, then runs the post-morph Suspense activation and
//! the single `mount_all()` that is the sole mount point for a nav.
//!
//! ## Why the callbacks behave as they do
//!
//! A mounted island owns its subtree through signals and effects; the morph must
//! not reconcile inside it (AC-V6). In this faithful idiomorph port,
//! `before_node_morphed` returning [`CallbackOutcome::Skip`] leaves the old node
//! **entirely** untouched — neither its attributes nor its children are morphed
//! (see `islands-morph` `morph_node`). That is exactly what we want for an
//! unchanged island. When `data-island-props` *changed*, though, we must (a)
//! copy the new props onto the live node ourselves (the skip means the morph
//! won't), then (b) `unmount_island` it so the single post-morph `mount_all`
//! re-mounts it fresh against those new props.
//!
//! Server HTML must never carry client bookkeeping, so the incoming new-doc
//! marker has `data-island-scope-id` / `data-island-mounted` stripped both when
//! it morphs an existing marker and when it is added fresh (AC-V14).

use wasm_bindgen::JsCast;
use wasm_bindgen::JsValue;

use islands_morph::{
    AfterNodeAdded, BeforeNodeAdded, BeforeNodeMorphed, BeforeNodeRemoved, CallbackOutcome,
    HeadConfig, MorphCallbacks, MorphOptions, MorphStyle,
};

use crate::{mount_all, unmount_island};

use super::{dom, prefetch};

/// Attribute marking a mounted island instance.
const ATTRIBUTE_MOUNTED: &str = "data-island-mounted";
/// Attribute holding the runtime-minted scope identifier for a mounted island.
const ATTRIBUTE_SCOPE_ID: &str = "data-island-scope-id";
/// Attribute carrying the server-serialized island props.
const ATTRIBUTE_PROPS: &str = "data-island-props";
/// Attribute naming the island a marker mounts.
const ATTRIBUTE_ISLAND: &str = "data-island";

/// Whether a node is a mounted island marker (`[data-island][data-island-mounted]`).
fn is_mounted_island(node: &web_sys::Node) -> bool {
    let element = match node.dyn_ref::<web_sys::Element>() {
        Some(element) => element,
        None => return false,
    };
    element.has_attribute(ATTRIBUTE_ISLAND) && element.has_attribute(ATTRIBUTE_MOUNTED)
}

/// Strip the client-only bookkeeping attributes from an incoming new-doc node so
/// server markup can never inject a stale scope id / mounted flag (AC-V14).
fn strip_client_state(node: &web_sys::Node) {
    let element = match node.dyn_ref::<web_sys::Element>() {
        Some(element) => element,
        None => return,
    };
    let _ = element.remove_attribute(ATTRIBUTE_SCOPE_ID);
    let _ = element.remove_attribute(ATTRIBUTE_MOUNTED);
}

/// Read a node's `data-island-props` value (absent → `None`).
fn props_of(node: &web_sys::Node) -> Option<String> {
    node.dyn_ref::<web_sys::Element>()
        .and_then(|element| element.get_attribute(ATTRIBUTE_PROPS))
}

/// Read a node's `data-island` value — the island name — or `None` when the
/// node is not an element or carries no island marker.
fn island_name_of(node: &web_sys::Node) -> Option<String> {
    node.dyn_ref::<web_sys::Element>()
        .and_then(|element| element.get_attribute(ATTRIBUTE_ISLAND))
}

/// Build the [`MorphOptions`] for a nav morph, with island lifecycle bound to
/// the runtime.
///
/// Constructed so it compiles in BOTH `islands-morph` feature configurations:
/// `morph_style` + `callbacks` + `head` are set explicitly and the rest comes
/// from `Default` (the lean build still has `restore_focus`, just ignored, and
/// `HeadConfig::default()` is `Merge`-only there).
///
/// AC-V5/V14 (a departing island's `Scope` cleaned up exactly once, no leaked
/// `SCOPE_REGISTRY` entries) depend on `islands-morph`'s `pantry` feature staying
/// OFF on the nav dependency. With `pantry` on, idiomorph's node-removal parks
/// any subtree containing a persistent id into the pantry and returns early,
/// BYPASSING `before_node_removed` — so a parked, departing island would skip
/// `unmount_island` and leak its `Scope`. The nav dependency is declared
/// `default-features = false` (pantry off) in `Cargo.toml` precisely for this.
/// Do NOT enable `pantry` on it without first adding a park-but-still-unmount
/// path that runs the island's cleanup before the subtree is parked.
pub(crate) fn nav_morph_options() -> MorphOptions {
    let before_node_removed: BeforeNodeRemoved = Box::new(|node: &web_sys::Node| {
        // A departing island must run its Scope cleanup exactly once before the
        // node leaves the DOM. Non-island nodes are removed normally.
        if is_mounted_island(node) {
            if let Some(element) = node.dyn_ref::<web_sys::Element>() {
                let _ = unmount_island(element);
            }
        }
        CallbackOutcome::Continue
    });

    let before_node_morphed: BeforeNodeMorphed =
        Box::new(|old_node: &web_sys::Node, new_content: &web_sys::Node| {
            // Server HTML never carries client bookkeeping (AC-V14).
            strip_client_state(new_content);

            if !is_mounted_island(old_node) {
                // Not a mounted island — let the normal morph reconcile it.
                return CallbackOutcome::Continue;
            }

            // Preserve the live island ONLY when its marker is truly unchanged —
            // same island name AND same props. Then its signal-managed subtree is
            // island-owned, so we Skip (never recurse) and its in-flight state
            // survives the nav (AC-V6's keep-alive case).
            let unchanged = island_name_of(old_node) == island_name_of(new_content)
                && props_of(old_node) == props_of(new_content);
            if unchanged {
                return CallbackOutcome::Skip;
            }

            // Otherwise the position now holds a different island, or the same
            // island with changed props. Either way the old instance must be torn
            // down and the node fully replaced with the new server markup —
            // crucially, replacing the CHILDREN too: two pages may reuse one
            // island name with different DOM (e.g. `/`'s single-button `Counter`
            // vs `/random`'s count+roll `Counter`), so a Skip would leave the old
            // structure and the new island's mount fn would fail to find its
            // elements. Unmount the old scope, then return Continue so the normal
            // morph rewrites name/props/children and the single post-morph
            // mount_all mounts the new island against fresh markup.
            if let Some(element) = old_node.dyn_ref::<web_sys::Element>() {
                let _ = unmount_island(element);
            }
            CallbackOutcome::Continue
        });

    // Added islands must also not inherit server-side client state (AC-V14).
    // Adding never mounts — mounting is single-sourced at step 6.
    let before_node_added: BeforeNodeAdded = Box::new(|node: &web_sys::Node| {
        strip_client_state(node);
        CallbackOutcome::Continue
    });
    let after_node_added: AfterNodeAdded = Box::new(|_node: &web_sys::Node| {
        // Intentionally does NOT mount. mount_all() at step 6 is the sole mount.
    });

    let callbacks = MorphCallbacks {
        before_node_added: Some(before_node_added),
        after_node_added: Some(after_node_added),
        before_node_morphed: Some(before_node_morphed),
        before_node_removed: Some(before_node_removed),
        ..MorphCallbacks::default()
    };

    MorphOptions {
        morph_style: MorphStyle::OuterHtml,
        callbacks,
        head: HeadConfig::default(),
        ..MorphOptions::default()
    }
}

/// Activate any streamed-Suspense leftovers carried in the morphed DOM
/// (contract step 5), then `mount_all()` once (step 6).
///
/// A client `fetch` of a streaming route returns the fully-drained stream: the
/// island markers sit inside `<template id="T:N">` paired with a
/// `[data-suspense-slot]` placeholder (the server's content-negotiated inline
/// path emits none, in which case this is a no-op — it is unconditional and
/// self-gating, so the negotiated and fallback paths can never double-apply).
/// For each surviving pair we run the `$ISLANDS_REPLACE` swap
/// (`replaceChildren(template.content)`), then mount everything exactly once.
///
/// After the mount, re-observe the now-current anchors so links the morph
/// brought in get the viewport prefetch tier (idempotent on already-observed
/// shell links — see [`prefetch::observe_current_anchors`]).
pub(crate) fn activate_suspense_then_mount() -> Result<(), JsValue> {
    activate_suspense_slots()?;
    mount_all()?;
    prefetch::observe_current_anchors();
    Ok(())
}

/// Run the `$ISLANDS_REPLACE` swap for every surviving
/// `template[id^="T:"]` / `[data-suspense-slot]` pair in the document.
///
/// Mirrors `crates/islands-core/src/suspense.rs`'s `REPLACE_SCRIPT` but in Rust,
/// without re-running its inline `__islands_remount` (the caller mounts once
/// afterward). Self-gating: when no `template[id^="T:"]` survives, the loop body
/// never runs.
fn activate_suspense_slots() -> Result<(), JsValue> {
    let document = dom::document()?;
    let templates = document.query_selector_all("template[id^=\"T:\"]")?;
    for index in 0..templates.length() {
        let node = match templates.get(index) {
            Some(node) => node,
            None => continue,
        };
        let template = match node.dyn_into::<web_sys::HtmlTemplateElement>() {
            Ok(template) => template,
            Err(_) => continue,
        };
        // The template id is "T:N"; the paired slot is "S:N".
        let template_id = template.id();
        let slot_id = match template_id.strip_prefix("T:") {
            Some(suffix) => format!("S:{suffix}"),
            None => continue,
        };
        let slot = match document.get_element_by_id(&slot_id) {
            Some(slot) => slot,
            None => continue,
        };
        // replaceChildren(template.content) — swap the resolved body in.
        let content = template.content();
        slot.replace_children_with_node_1(content.as_ref());
        template.remove();
    }
    Ok(())
}