islands-runtime 0.1.3

The shared WASM runtime for islands.rs: reactive Signal/Scope/effect primitives and idempotent island mounting, emitted as islands_core.{js,wasm}.
Documentation
//! History, scroll, and focus management (contract step 7) plus the popstate
//! and bfcache wiring.
//!
//! Ordering, per the normative contract:
//!   - 7a: capture the *outgoing* scroll position into the current
//!     `history.state`, then `pushState` the new entry. Runs BEFORE the morph's
//!     view-transition wrapper so the new entry exists when the morph paints.
//!   - 7b: after a `requestAnimationFrame` (so the morph has painted), set the
//!     *incoming* scroll — the stored position on back/forward, the top on a
//!     forward nav.
//!   - 7c: move focus to `<main>` on a forward nav for keyboard / screen-reader
//!     users.
//!
//! `history.scrollRestoration` is set to `"manual"` once at init so the browser
//! does not fight our restore. A `pageshow` with `persisted === true` (bfcache
//! restore) reinstates the live DOM wholesale and MUST NOT trigger a morph — the
//! init guard does not re-run and only `popstate` drives nav-morphs.

use wasm_bindgen::closure::Closure;
use wasm_bindgen::JsCast;
use wasm_bindgen::JsValue;

use super::dom;

/// Marker key written into every nav `history.state` so a popstate can tell our
/// entries from foreign ones.
const STATE_FLAG: &str = "islandsNav";
/// `history.state` key for the horizontal scroll offset.
const STATE_SCROLL_X: &str = "islandsScrollX";
/// `history.state` key for the vertical scroll offset.
const STATE_SCROLL_Y: &str = "islandsScrollY";

/// Set `history.scrollRestoration = "manual"` so we own scroll restoration.
/// Best-effort: a browser without the property is simply left on its default.
pub(crate) fn set_manual_scroll_restoration() -> Result<(), JsValue> {
    let history = dom::window()?.history()?;
    let _ = history.set_scroll_restoration(web_sys::ScrollRestoration::Manual);
    Ok(())
}

/// A captured scroll position.
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct ScrollPosition {
    pub x: f64,
    pub y: f64,
}

/// Read the window's current scroll offset.
fn current_scroll() -> ScrollPosition {
    let window = match dom::window() {
        Ok(window) => window,
        Err(_) => return ScrollPosition::default(),
    };
    ScrollPosition {
        x: window.scroll_x().unwrap_or(0.0),
        y: window.scroll_y().unwrap_or(0.0),
    }
}

/// Build a `history.state` object tagging it as ours and recording `scroll`.
fn build_state(scroll: ScrollPosition) -> JsValue {
    let state = js_sys::Object::new();
    let _ = js_sys::Reflect::set(&state, &JsValue::from_str(STATE_FLAG), &JsValue::TRUE);
    let _ = js_sys::Reflect::set(
        &state,
        &JsValue::from_str(STATE_SCROLL_X),
        &JsValue::from_f64(scroll.x),
    );
    let _ = js_sys::Reflect::set(
        &state,
        &JsValue::from_str(STATE_SCROLL_Y),
        &JsValue::from_f64(scroll.y),
    );
    state.into()
}

/// Read a `ScrollPosition` out of a `history.state` value, defaulting to the
/// origin when the keys are absent.
pub(crate) fn scroll_from_state(state: &JsValue) -> ScrollPosition {
    let read = |key: &str| -> f64 {
        js_sys::Reflect::get(state, &JsValue::from_str(key))
            .ok()
            .and_then(|value| value.as_f64())
            .unwrap_or(0.0)
    };
    ScrollPosition {
        x: read(STATE_SCROLL_X),
        y: read(STATE_SCROLL_Y),
    }
}

/// Whether a `history.state` value is one of ours (carries the [`STATE_FLAG`]).
pub(crate) fn is_nav_state(state: &JsValue) -> bool {
    js_sys::Reflect::get(state, &JsValue::from_str(STATE_FLAG))
        .ok()
        .and_then(|value| value.as_bool())
        .unwrap_or(false)
}

/// Contract step 7a: record the outgoing scroll into the *current* entry's state
/// (so a later back-nav can restore it), then push the new `url` as a fresh
/// entry whose own state starts at the top.
pub(crate) fn capture_scroll_and_push(url: &str) -> Result<(), JsValue> {
    let history = dom::window()?.history()?;

    // Update the entry we are leaving with where the user had scrolled to.
    let outgoing = build_state(current_scroll());
    let _ = history.replace_state_with_url(&outgoing, "", None);

    // Push the destination as a new entry; its stored scroll starts at the top
    // (a forward nav resets scroll — see restore_incoming_scroll).
    let incoming = build_state(ScrollPosition::default());
    history.push_state_with_url(&incoming, "", Some(url))?;
    Ok(())
}

/// Contract step 7b: after the next animation frame (the morph has painted), set
/// the window scroll to `target`. Restoring post-paint avoids a visible jump.
pub(crate) fn restore_scroll_next_frame(target: ScrollPosition) -> Result<(), JsValue> {
    let window = dom::window()?;
    let window_for_callback = window.clone();
    let callback = Closure::once_into_js(move || {
        window_for_callback.scroll_to_with_x_and_y(target.x, target.y);
    });
    window.request_animation_frame(callback.as_ref().unchecked_ref())?;
    Ok(())
}

/// Contract step 7c: move focus to `<main>` (the configured focus target) so
/// keyboard and screen-reader users land in the new page's content. Best-effort:
/// no `<main>` simply leaves focus where it was.
pub(crate) fn focus_main() -> Result<(), JsValue> {
    let document = dom::document()?;
    let main = match document.query_selector("main")? {
        Some(main) => main,
        None => return Ok(()),
    };
    let main_element: web_sys::HtmlElement = match main.dyn_into() {
        Ok(element) => element,
        Err(_) => return Ok(()),
    };
    // Make a non-interactive <main> programmatically focusable without trapping
    // it in the tab order.
    if !main_element.has_attribute("tabindex") {
        let _ = main_element.set_attribute("tabindex", "-1");
    }
    let _ = main_element.focus();
    Ok(())
}