use std::time::{Duration, Instant};
use crate::{dom::Dom, js_runtime::BrowserJsRuntime};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IdleReason {
AllWorkDone,
Timeout,
}
pub struct BrowserEventLoop {
runtime: BrowserJsRuntime,
}
#[derive(Debug, thiserror::Error)]
pub enum EventLoopError {
#[error("js error: {0}")]
Js(#[from] crate::js_runtime::JsError),
#[error("event loop timeout")]
Timeout,
#[error("event loop error: {0}")]
Other(String),
}
const RAF_BOOTSTRAP: &str = r#"
(() => {
if (globalThis.__rafCallbacks) return;
globalThis.__rafCallbacks = [];
globalThis.__rafScheduled = false;
globalThis.requestAnimationFrame = (cb) => {
const id = globalThis.__rafCallbacks.length + 1;
globalThis.__rafCallbacks.push({ id, cb });
if (!globalThis.__rafScheduled) {
globalThis.__rafScheduled = true;
}
return id;
};
globalThis.cancelAnimationFrame = (id) => {
globalThis.__rafCallbacks = globalThis.__rafCallbacks.filter(c => c.id !== id);
};
globalThis.__fireRafCallbacks = () => {
const cbs = globalThis.__rafCallbacks.splice(0);
globalThis.__rafScheduled = false;
const ts = typeof performance !== 'undefined' ? performance.now() : Date.now();
for (const { cb } of cbs) {
try { cb(ts); } catch (e) { console.error('rAF callback error:', e); }
}
};
})();
"#;
impl BrowserEventLoop {
pub fn new() -> Self {
let runtime = BrowserJsRuntime::new(Dom::new());
Self::with_runtime(runtime)
}
pub fn with_runtime(mut runtime: BrowserJsRuntime) -> Self {
let _ = runtime.execute_script(RAF_BOOTSTRAP);
Self { runtime }
}
pub fn with_runtime_raw(runtime: BrowserJsRuntime) -> Self {
Self { runtime }
}
pub async fn run_until_idle(
&mut self,
timeout: Duration,
) -> Result<IdleReason, EventLoopError> {
let deadline = Instant::now() + timeout;
loop {
if Instant::now() >= deadline {
return Ok(IdleReason::Timeout);
}
let _ = self.runtime.execute_script(
"if (globalThis.__fireRafCallbacks) globalThis.__fireRafCallbacks();",
);
let remaining = deadline.saturating_duration_since(Instant::now());
let tick_timeout = remaining.min(Duration::from_millis(100));
match tokio::time::timeout(tick_timeout, self.runtime.run_event_loop()).await {
Ok(Ok(())) => {
let _ = self.runtime.execute_script(
"if (globalThis.__fireRafCallbacks) globalThis.__fireRafCallbacks();",
);
let has_work = self
.runtime
.execute_script(
r#"
(globalThis.__rafCallbacks && globalThis.__rafCallbacks.length > 0) ? "true" : "false"
"#,
)
.unwrap_or_else(|_| "false".to_string());
if has_work == "true" {
continue;
}
return Ok(IdleReason::AllWorkDone);
}
Ok(Err(e)) => return Err(EventLoopError::Js(e)),
Err(_elapsed) => {
continue;
}
}
}
}
pub fn execute_script(&mut self, code: &str) -> Result<String, EventLoopError> {
self.runtime
.execute_script(code)
.map_err(EventLoopError::Js)
}
pub async fn execute_and_run(
&mut self,
code: &str,
timeout: Duration,
) -> Result<IdleReason, EventLoopError> {
self.execute_script(code)?;
self.run_until_idle(timeout).await
}
pub fn runtime(&self) -> &BrowserJsRuntime {
&self.runtime
}
pub fn runtime_mut(&mut self) -> &mut BrowserJsRuntime {
&mut self.runtime
}
pub fn into_runtime(self) -> BrowserJsRuntime {
self.runtime
}
}
impl Default for BrowserEventLoop {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_loop() -> BrowserEventLoop {
BrowserEventLoop::new()
}
#[tokio::test]
async fn idle_detection_no_work() {
let mut evloop = create_loop();
let reason = evloop.run_until_idle(Duration::from_secs(5)).await.unwrap();
assert_eq!(reason, IdleReason::AllWorkDone);
}
#[tokio::test]
async fn set_timeout_fires() {
let mut evloop = create_loop();
evloop
.execute_script(
r#"
globalThis.__timerResult = 'pending';
setTimeout(() => { globalThis.__timerResult = 'timer fired'; }, 50);
"#,
)
.unwrap();
let reason = evloop.run_until_idle(Duration::from_secs(5)).await.unwrap();
assert_eq!(reason, IdleReason::AllWorkDone);
let result = evloop.execute_script("globalThis.__timerResult").unwrap();
assert_eq!(result, "timer fired");
}
#[tokio::test]
async fn promise_resolves() {
let mut evloop = create_loop();
evloop
.execute_script(
r#"
globalThis.__promiseResult = 'pending';
Promise.resolve().then(() => { globalThis.__promiseResult = 'resolved'; });
"#,
)
.unwrap();
evloop.run_until_idle(Duration::from_secs(5)).await.unwrap();
let result = evloop.execute_script("globalThis.__promiseResult").unwrap();
assert_eq!(result, "resolved");
}
#[tokio::test]
async fn chained_set_timeout() {
let mut evloop = create_loop();
evloop
.execute_script(
r#"
globalThis.__chainResult = '';
setTimeout(() => {
globalThis.__chainResult += '1';
setTimeout(() => {
globalThis.__chainResult += '2';
}, 10);
}, 10);
"#,
)
.unwrap();
evloop.run_until_idle(Duration::from_secs(5)).await.unwrap();
let result = evloop.execute_script("globalThis.__chainResult").unwrap();
assert_eq!(result, "12");
}
#[tokio::test]
async fn request_animation_frame() {
let mut evloop = create_loop();
evloop
.execute_script(
r#"
globalThis.__rafResult = 'pending';
requestAnimationFrame((ts) => {
globalThis.__rafResult = 'raf:' + (typeof ts);
});
"#,
)
.unwrap();
evloop.run_until_idle(Duration::from_secs(5)).await.unwrap();
let result = evloop.execute_script("globalThis.__rafResult").unwrap();
assert_eq!(result, "raf:number");
}
#[tokio::test]
async fn raf_cancel() {
let mut evloop = create_loop();
evloop
.execute_script(
r#"
globalThis.__rafCancelResult = 'not fired';
const id = requestAnimationFrame(() => {
globalThis.__rafCancelResult = 'should not fire';
});
cancelAnimationFrame(id);
"#,
)
.unwrap();
evloop.run_until_idle(Duration::from_secs(5)).await.unwrap();
let result = evloop
.execute_script("globalThis.__rafCancelResult")
.unwrap();
assert_eq!(result, "not fired");
}
#[tokio::test]
async fn timeout_respected() {
let mut evloop = create_loop();
evloop
.execute_script("setTimeout(() => {}, 10000);")
.unwrap();
let reason = evloop
.run_until_idle(Duration::from_millis(200))
.await
.unwrap();
assert_eq!(reason, IdleReason::Timeout);
}
#[tokio::test]
async fn execute_and_run_smoke() {
let mut evloop = create_loop();
evloop.execute_script("globalThis.__ear = 'init';").unwrap();
let reason = evloop
.execute_and_run(
"setTimeout(() => { globalThis.__ear = 'done'; }, 10);",
Duration::from_secs(5),
)
.await
.unwrap();
assert_eq!(reason, IdleReason::AllWorkDone);
let result = evloop.execute_script("globalThis.__ear").unwrap();
assert_eq!(result, "done");
}
}