use crate::error::DriveError;
use runtime_headless::{
evaluate_script_value, wait_for_ready_state, wait_for_visible, HeadlessError, Page,
};
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct HydrationWait {
pub timeout: Duration,
pub selector: Option<String>,
pub wait_for_idle: bool,
}
impl Default for HydrationWait {
fn default() -> Self {
Self {
timeout: Duration::from_secs(10),
selector: None,
wait_for_idle: true,
}
}
}
pub async fn wait_for_spa_hydration(page: &Page, config: &HydrationWait) -> Result<(), DriveError> {
wait_for_ready_state(page, config.timeout)
.await
.map_err(|source: HeadlessError| DriveError::Headless(source.to_string()))?;
if let Some(selector) = &config.selector {
wait_for_visible(page, selector, config.timeout)
.await
.map_err(|source: HeadlessError| DriveError::Headless(source.to_string()))?;
}
if config.wait_for_idle {
let idle_script = r#"
async () => {
return new Promise(resolve => {
if ('requestIdleCallback' in window) {
window.requestIdleCallback(() => resolve("idle"), { timeout: 2000 });
} else {
setTimeout(() => resolve("timeout"), 500);
}
});
}
"#;
let _ = evaluate_script_value(page, idle_script).await;
const DOM_FLUSH_SCRIPT: &str = r#"
async () => {
return new Promise(resolve => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve("flushed")));
});
}
"#;
let flushed = await_dom_flush(
evaluate_script_value(page, DOM_FLUSH_SCRIPT),
config.timeout,
)
.await;
if !flushed {
tracing::debug!("SPA hydration DOM flush did not confirm before timeout; proceeding");
}
}
Ok(())
}
async fn await_dom_flush<T, E>(
flush: impl std::future::Future<Output = Result<T, E>>,
timeout: Duration,
) -> bool {
matches!(tokio::time::timeout(timeout, flush).await, Ok(Ok(_)))
}
#[cfg(test)]
mod tests {
use super::await_dom_flush;
use std::time::{Duration, Instant};
#[tokio::test]
async fn await_dom_flush_returns_immediately_when_dom_is_settled() {
let start = Instant::now();
let flushed = await_dom_flush(async { Ok::<(), ()>(()) }, Duration::from_secs(5)).await;
let elapsed = start.elapsed();
assert!(flushed, "a ready flush must report success");
assert!(
elapsed < Duration::from_millis(50),
"an immediate DOM flush must not pay the old fixed 100ms floor, took {elapsed:?}"
);
}
#[tokio::test]
async fn await_dom_flush_is_bounded_by_timeout_when_flush_hangs() {
let start = Instant::now();
let flushed = await_dom_flush(
std::future::pending::<Result<(), ()>>(),
Duration::from_millis(80),
)
.await;
let elapsed = start.elapsed();
assert!(
!flushed,
"a hung flush must time out and report non-completion"
);
assert!(
elapsed >= Duration::from_millis(80),
"must wait out the timeout budget, waited only {elapsed:?}"
);
assert!(
elapsed < Duration::from_secs(2),
"must not hang far past the timeout, took {elapsed:?}"
);
}
#[tokio::test]
async fn await_dom_flush_reports_failure_when_eval_errors() {
let flushed = await_dom_flush(async { Err::<(), ()>(()) }, Duration::from_secs(5)).await;
assert!(!flushed, "an errored flush must report non-completion");
}
}