use std::cell::RefCell;
use std::collections::HashMap;
use wasm_bindgen::closure::Closure;
use wasm_bindgen::JsCast;
use wasm_bindgen::JsValue;
use super::{dom, manifest, opt_out};
const MAX_SPECULATIONS: usize = 4;
const SPECULATION_TTL_MILLIS: i32 = 10_000;
const HOVER_DWELL_MILLIS: i32 = 65;
pub(crate) struct Speculation {
body_text_promise: js_sys::Promise,
controller: web_sys::AbortController,
ttl_timeout_handle: i32,
}
impl Speculation {
pub(crate) fn body_text_promise(&self) -> js_sys::Promise {
self.body_text_promise.clone()
}
pub(crate) fn into_controller(self) -> web_sys::AbortController {
self.controller
}
}
thread_local! {
static SPECULATIONS: RefCell<HashMap<String, Speculation>> =
RefCell::new(HashMap::new());
static WARMED_MODULES: RefCell<HashMap<String, ()>> = RefCell::new(HashMap::new());
static HOVER_DWELL_HANDLE: RefCell<Option<i32>> = const { RefCell::new(None) };
static VIEWPORT_OBSERVER: RefCell<Option<web_sys::IntersectionObserver>> =
const { RefCell::new(None) };
}
pub(crate) fn consume_speculation(url: &str) -> Option<Speculation> {
SPECULATIONS.with(|cache| {
let speculation = cache.borrow_mut().remove(url)?;
clear_timeout(speculation.ttl_timeout_handle);
Some(speculation)
})
}
fn save_data_enabled() -> bool {
let navigator = match dom::window() {
Ok(window) => window.navigator(),
Err(_) => return false,
};
let connection = match js_sys::Reflect::get(&navigator, &JsValue::from_str("connection")) {
Ok(connection) if !connection.is_undefined() && !connection.is_null() => connection,
_ => return false,
};
js_sys::Reflect::get(&connection, &JsValue::from_str("saveData"))
.ok()
.and_then(|value| value.as_bool())
.unwrap_or(false)
}
fn warm_anchor(anchor: &web_sys::HtmlAnchorElement) {
if save_data_enabled() {
return;
}
let document_origin = match dom::document_origin() {
Ok(origin) => origin,
Err(_) => return,
};
let link = dom::link_click_for_prefetch(anchor, document_origin);
if !opt_out::should_intercept(&link) {
return;
}
let url = anchor.href();
let pathname = match pathname_of(&url) {
Some(pathname) => pathname,
None => return,
};
let entry = match manifest::resolved_entry(&pathname) {
Some(entry) => entry,
None => return,
};
warm_module(&entry.js);
warm_fetch(&url);
}
fn warm_module(js_url: &str) {
let already = WARMED_MODULES.with(|warmed| {
if warmed.borrow().contains_key(js_url) {
return true;
}
warmed.borrow_mut().insert(js_url.to_owned(), ());
false
});
if already {
return;
}
let _ = super::dynamic_import(js_url);
}
fn warm_fetch(url: &str) {
let exists = SPECULATIONS.with(|cache| cache.borrow().contains_key(url));
if exists {
return;
}
if let Err(error) = start_speculation_fetch(url) {
web_sys::console::warn_2(
&JsValue::from_str("islands nav: prefetch warm fetch failed:"),
&error,
);
}
}
fn start_speculation_fetch(url: &str) -> Result<(), JsValue> {
let controller = web_sys::AbortController::new()?;
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 fetch_promise = window.fetch_with_request(&request);
let body_text_promise = wasm_bindgen_futures::future_to_promise(async move {
let response_value = wasm_bindgen_futures::JsFuture::from(fetch_promise).await?;
let response: web_sys::Response = response_value.dyn_into()?;
if !response.ok() {
return Err(JsValue::from_str(&format!(
"prefetch response not ok: HTTP {}",
response.status()
)));
}
wasm_bindgen_futures::JsFuture::from(response.text()?).await
});
let ttl_timeout_handle = install_ttl_eviction(&window, url)?;
admit_speculation(
url,
Speculation {
body_text_promise,
controller,
ttl_timeout_handle,
},
);
Ok(())
}
fn install_ttl_eviction(window: &web_sys::Window, url: &str) -> Result<i32, JsValue> {
let url_owned = url.to_owned();
let evict = Closure::once_into_js(move || {
evict_speculation(&url_owned);
});
window.set_timeout_with_callback_and_timeout_and_arguments_0(
evict.unchecked_ref(),
SPECULATION_TTL_MILLIS,
)
}
fn admit_speculation(url: &str, speculation: Speculation) {
let to_evict = SPECULATIONS.with(|cache| {
let cache_ref = cache.borrow();
if cache_ref.len() < MAX_SPECULATIONS {
return None;
}
cache_ref.keys().next().cloned()
});
if let Some(victim) = to_evict {
evict_speculation(&victim);
}
SPECULATIONS.with(|cache| {
cache.borrow_mut().insert(url.to_owned(), speculation);
});
}
fn evict_speculation(url: &str) {
if let Some(speculation) = SPECULATIONS.with(|cache| cache.borrow_mut().remove(url)) {
clear_timeout(speculation.ttl_timeout_handle);
speculation.controller.abort();
}
}
pub(crate) fn attach_warm_listeners() -> Result<(), JsValue> {
attach_hover_focus_listeners()?;
attach_viewport_observer()?;
Ok(())
}
fn attach_hover_focus_listeners() -> Result<(), JsValue> {
let document = dom::document()?;
let hover_handler = Closure::<dyn FnMut(web_sys::Event)>::new(move |event: web_sys::Event| {
schedule_warm_from_event(&event);
});
document.add_event_listener_with_callback_and_bool(
"mouseover",
hover_handler.as_ref().unchecked_ref(),
true,
)?;
hover_handler.forget();
let focus_handler = Closure::<dyn FnMut(web_sys::Event)>::new(move |event: web_sys::Event| {
schedule_warm_from_event(&event);
});
document.add_event_listener_with_callback_and_bool(
"focusin",
focus_handler.as_ref().unchecked_ref(),
true,
)?;
focus_handler.forget();
Ok(())
}
fn schedule_warm_from_event(event: &web_sys::Event) {
let target = match event.target() {
Some(target) => target,
None => return,
};
let anchor = match dom::closest_anchor(&target) {
Some(anchor) => anchor,
None => return,
};
cancel_pending_dwell();
let window = match dom::window() {
Ok(window) => window,
Err(_) => return,
};
let dwell = Closure::once_into_js(move || {
HOVER_DWELL_HANDLE.with(|handle| *handle.borrow_mut() = None);
warm_anchor(&anchor);
});
if let Ok(handle) = window.set_timeout_with_callback_and_timeout_and_arguments_0(
dwell.unchecked_ref(),
HOVER_DWELL_MILLIS,
) {
HOVER_DWELL_HANDLE.with(|stored| *stored.borrow_mut() = Some(handle));
}
}
fn cancel_pending_dwell() {
HOVER_DWELL_HANDLE.with(|handle| {
if let Some(pending) = handle.borrow_mut().take() {
clear_timeout(pending);
}
});
}
fn attach_viewport_observer() -> Result<(), JsValue> {
let callback = Closure::<dyn FnMut(js_sys::Array)>::new(move |entries: js_sys::Array| {
for entry_value in entries.iter() {
let entry: web_sys::IntersectionObserverEntry = match entry_value.dyn_into() {
Ok(entry) => entry,
Err(_) => continue,
};
if !entry.is_intersecting() {
continue;
}
if let Ok(anchor) = entry.target().dyn_into::<web_sys::HtmlAnchorElement>() {
warm_anchor(&anchor);
}
}
});
let observer = web_sys::IntersectionObserver::new(callback.as_ref().unchecked_ref())?;
callback.forget();
VIEWPORT_OBSERVER.with(|slot| *slot.borrow_mut() = Some(observer));
observe_current_anchors();
Ok(())
}
pub(crate) fn observe_current_anchors() {
VIEWPORT_OBSERVER.with(|slot| {
let borrowed = slot.borrow();
let observer = match borrowed.as_ref() {
Some(observer) => observer,
None => return,
};
let document = match dom::document() {
Ok(document) => document,
Err(_) => return,
};
let anchors = match document.query_selector_all("a[href]") {
Ok(anchors) => anchors,
Err(_) => return,
};
for index in 0..anchors.length() {
if let Some(node) = anchors.get(index) {
if let Ok(element) = node.dyn_into::<web_sys::Element>() {
observer.observe(&element);
}
}
}
});
}
fn clear_timeout(handle: i32) {
if let Ok(window) = dom::window() {
window.clear_timeout_with_handle(handle);
}
}
fn pathname_of(url: &str) -> Option<String> {
let base = dom::window().ok()?.location().href().ok()?;
web_sys::Url::new_with_base(url, &base)
.ok()
.map(|parsed| parsed.pathname())
}