mod dom;
mod history;
mod lifecycle;
mod manifest;
mod opt_out;
mod prefetch;
mod view_transition;
use std::cell::RefCell;
use wasm_bindgen::closure::Closure;
use wasm_bindgen::JsCast;
use wasm_bindgen::JsValue;
use wasm_bindgen_futures::{spawn_local, JsFuture};
use crate::nav::history::ScrollPosition;
thread_local! {
static INITIALIZED: RefCell<bool> = const { RefCell::new(false) };
static ACTIVE_FETCH_CONTROLLER: RefCell<Option<web_sys::AbortController>> =
const { RefCell::new(None) };
static NAV_GENERATION: RefCell<u32> = const { RefCell::new(0) };
}
fn begin_nav_generation() -> u32 {
abort_active_fetch();
NAV_GENERATION.with(|generation| {
let next = generation.borrow().wrapping_add(1);
*generation.borrow_mut() = next;
next
})
}
fn is_current_generation(generation: u32) -> bool {
NAV_GENERATION.with(|current| *current.borrow() == generation)
}
pub fn init() {
let already = INITIALIZED.with(|flag| {
if *flag.borrow() {
return true;
}
*flag.borrow_mut() = true;
false
});
if already {
return;
}
if let Err(error) = wire_up() {
web_sys::console::error_2(
&JsValue::from_str("islands nav: init failed:"),
&error,
);
}
}
fn wire_up() -> Result<(), JsValue> {
history::set_manual_scroll_restoration()?;
attach_click_interceptor()?;
attach_popstate_listener()?;
prefetch::attach_warm_listeners()?;
Ok(())
}
pub fn navigate(url: &str) {
let url_owned = url.to_owned();
let generation = begin_nav_generation();
spawn_local(async move {
if let Err(error) = navigate_to(&url_owned, generation).await {
if is_current_generation(generation) {
web_sys::console::error_2(
&JsValue::from_str("islands nav: navigation failed, falling back to full load:"),
&error,
);
fall_back_to_full_load(&url_owned);
}
}
});
}
fn attach_click_interceptor() -> Result<(), JsValue> {
let document = dom::document()?;
let handler = Closure::<dyn FnMut(web_sys::Event)>::new(move |event: web_sys::Event| {
if let Err(error) = handle_click(&event) {
web_sys::console::error_2(&JsValue::from_str("islands nav: click handler error:"), &error);
}
});
document.add_event_listener_with_callback_and_bool(
"click",
handler.as_ref().unchecked_ref(),
true,
)?;
handler.forget();
Ok(())
}
fn handle_click(event: &web_sys::Event) -> Result<(), JsValue> {
let mouse_event = match event.dyn_ref::<web_sys::MouseEvent>() {
Some(mouse_event) => mouse_event,
None => return Ok(()),
};
if mouse_event.default_prevented() {
return Ok(());
}
let target = match event.target() {
Some(target) => target,
None => return Ok(()),
};
let anchor = match dom::closest_anchor(&target) {
Some(anchor) => anchor,
None => return Ok(()),
};
let link = dom::link_click_from(mouse_event, &anchor, dom::document_origin()?);
if !opt_out::should_intercept(&link) {
return Ok(());
}
event.prevent_default();
let url = anchor.href();
if let Ok(current) = dom::window().and_then(|window| window.location().href()) {
if url == current {
return Ok(());
}
}
navigate(&url);
Ok(())
}
fn attach_popstate_listener() -> Result<(), JsValue> {
let window = dom::window()?;
let handler = Closure::<dyn FnMut(web_sys::PopStateEvent)>::new(
move |event: web_sys::PopStateEvent| {
handle_popstate(&event);
},
);
window.add_event_listener_with_callback("popstate", handler.as_ref().unchecked_ref())?;
handler.forget();
Ok(())
}
fn handle_popstate(event: &web_sys::PopStateEvent) {
let state = event.state();
let url = match dom::window().and_then(|window| window.location().href()) {
Ok(url) => url,
Err(_) => return,
};
if !history::is_nav_state(&state) {
fall_back_to_full_load(&url);
return;
}
let target_scroll = history::scroll_from_state(&state);
let generation = begin_nav_generation();
spawn_local(async move {
if let Err(error) = navigate_via_popstate(&url, target_scroll, generation).await {
if is_current_generation(generation) {
web_sys::console::error_2(
&JsValue::from_str("islands nav: popstate navigation failed, full load:"),
&error,
);
fall_back_to_full_load(&url);
}
}
});
}
async fn navigate_to(url: &str, generation: u32) -> Result<(), JsValue> {
let entry = match resolve_entry(url).await? {
Some(entry) => entry,
None => {
if is_current_generation(generation) {
fall_back_to_full_load(url);
}
return Ok(());
}
};
if !is_current_generation(generation) {
return Ok(());
}
load_and_instantiate_bundle(&entry.js).await?;
if !is_current_generation(generation) {
return Ok(());
}
let html = obtain_destination_html(url).await?;
if !is_current_generation(generation) {
return Ok(());
}
history::capture_scroll_and_push(url)?;
view_transition::run_morph_with_optional_transition(move || perform_morph_and_mount(&html))
.await?;
history::restore_scroll_next_frame(ScrollPosition::default())?;
history::focus_main()?;
clear_active_controller();
Ok(())
}
async fn navigate_via_popstate(
url: &str,
target: ScrollPosition,
generation: u32,
) -> Result<(), JsValue> {
let entry = match resolve_entry(url).await? {
Some(entry) => entry,
None => {
if is_current_generation(generation) {
fall_back_to_full_load(url);
}
return Ok(());
}
};
if !is_current_generation(generation) {
return Ok(());
}
load_and_instantiate_bundle(&entry.js).await?;
if !is_current_generation(generation) {
return Ok(());
}
let html = obtain_destination_html(url).await?;
if !is_current_generation(generation) {
return Ok(());
}
view_transition::run_morph_with_optional_transition(move || perform_morph_and_mount(&html))
.await?;
history::restore_scroll_next_frame(target)?;
clear_active_controller();
Ok(())
}
fn perform_morph_and_mount(html: &str) -> Result<(), JsValue> {
let new_document = parse_html_document(html)?;
let new_root = new_document
.document_element()
.ok_or_else(|| JsValue::from_str("fetched document has no documentElement"))?;
let live_root = dom::document()?
.document_element()
.ok_or_else(|| JsValue::from_str("live document has no documentElement"))?;
islands_morph::morph(
live_root.as_ref(),
new_root.as_ref(),
lifecycle::nav_morph_options(),
)?;
lifecycle::activate_suspense_then_mount()
}
async fn resolve_entry(url: &str) -> Result<Option<manifest::NavEntry>, JsValue> {
let pathname = pathname_of(url)?;
manifest::ensure_cached().await?;
Ok(manifest::resolved_entry(&pathname))
}
async fn load_and_instantiate_bundle(js_url: &str) -> Result<(), JsValue> {
let module = JsFuture::from(dynamic_import(js_url)).await?;
let default_export = js_sys::Reflect::get(&module, &JsValue::from_str("default"))?;
let default_function = default_export
.dyn_ref::<js_sys::Function>()
.ok_or_else(|| JsValue::from_str("page bundle has no default export function"))?;
let init_result = default_function.call0(&module)?;
if let Ok(promise) = init_result.dyn_into::<js_sys::Promise>() {
JsFuture::from(promise).await?;
}
Ok(())
}
async fn obtain_destination_html(url: &str) -> Result<String, JsValue> {
let parked_body = prefetch::consume_speculation(url).map(|speculation| {
let body_text_promise = speculation.body_text_promise();
set_active_controller(speculation.into_controller());
body_text_promise
});
if let Some(body_text_promise) = parked_body {
let text_value = JsFuture::from(body_text_promise).await?;
return text_value
.as_string()
.ok_or_else(|| JsValue::from_str("prefetched body was not a string"));
}
let controller = install_active_controller()?;
fetch_destination_html(url, &controller).await
}
async fn fetch_destination_html(
url: &str,
controller: &web_sys::AbortController,
) -> Result<String, JsValue> {
let headers = web_sys::Headers::new()?;
headers.set(manifest::NAV_HEADER_NAME, manifest::NAV_HEADER_VALUE)?;
let options = web_sys::RequestInit::new();
options.set_headers(&headers);
options.set_signal(Some(&controller.signal()));
let request = web_sys::Request::new_with_str_and_init(url, &options)?;
let window = dom::window()?;
let response_value = JsFuture::from(window.fetch_with_request(&request)).await?;
let response: web_sys::Response = response_value.dyn_into()?;
if !response.ok() {
return Err(JsValue::from_str(&format!(
"destination fetch failed: HTTP {}",
response.status()
)));
}
let text_value = JsFuture::from(response.text()?).await?;
text_value
.as_string()
.ok_or_else(|| JsValue::from_str("destination body was not a string"))
}
fn parse_html_document(html: &str) -> Result<web_sys::Document, JsValue> {
let parser = web_sys::DomParser::new()?;
parser.parse_from_string(html, web_sys::SupportedType::TextHtml)
}
fn install_active_controller() -> Result<web_sys::AbortController, JsValue> {
let controller = web_sys::AbortController::new()?;
set_active_controller(controller.clone());
Ok(controller)
}
fn set_active_controller(controller: web_sys::AbortController) {
ACTIVE_FETCH_CONTROLLER.with(|slot| *slot.borrow_mut() = Some(controller));
}
fn abort_active_fetch() {
ACTIVE_FETCH_CONTROLLER.with(|slot| {
if let Some(controller) = slot.borrow_mut().take() {
controller.abort();
}
});
}
fn clear_active_controller() {
ACTIVE_FETCH_CONTROLLER.with(|slot| *slot.borrow_mut() = None);
}
fn fall_back_to_full_load(url: &str) {
if let Ok(window) = dom::window() {
let _ = window.location().assign(url);
}
}
fn pathname_of(url: &str) -> Result<String, JsValue> {
let base = dom::window()?.location().href()?;
let parsed = web_sys::Url::new_with_base(url, &base)?;
Ok(parsed.pathname())
}
fn dynamic_import(specifier: &str) -> js_sys::Promise {
dynamic_import_glue(specifier)
}
#[wasm_bindgen::prelude::wasm_bindgen(inline_js = r#"
export function __islands_dynamic_import(specifier) { return import(specifier); }
"#)]
extern "C" {
#[wasm_bindgen(js_name = __islands_dynamic_import)]
fn dynamic_import_glue(specifier: &str) -> js_sys::Promise;
}