diff --git a/packages/router/Cargo.toml b/packages/router/Cargo.toml index 80923d539..da9d3fe85 100644 --- a/packages/router/Cargo.toml +++ b/packages/router/Cargo.toml @@ -19,6 +19,7 @@ thiserror = { workspace = true } futures-util = { workspace = true } urlencoding = "2.1.3" serde = { version = "1", features = ["derive"], optional = true } +serde_json = { version = "1.0.91", optional = true } url = "2.3.1" wasm-bindgen = { workspace = true, optional = true } web-sys = { version = "0.3.60", optional = true, features = [ @@ -26,18 +27,22 @@ web-sys = { version = "0.3.60", optional = true, features = [ ] } js-sys = { version = "0.3.63", optional = true } gloo-utils = { version = "0.1.6", optional = true } +dioxus-liveview = { workspace = true, optional = true } dioxus-ssr = { workspace = true, optional = true } tokio = { workspace = true, features = ["full"], optional = true } [features] default = ["web"] ssr = ["dioxus-ssr", "tokio"] +liveview = ["dioxus-liveview", "tokio", "dep:serde", "serde_json"] wasm_test = [] serde = ["dep:serde", "gloo-utils/serde"] web = ["gloo", "web-sys", "wasm-bindgen", "gloo-utils", "js-sys"] [dev-dependencies] +axum = { version = "0.6.1", features = ["ws"] } dioxus = { path = "../dioxus" } +dioxus-liveview = { workspace = true, features = ["axum"] } dioxus-ssr = { path = "../ssr" } criterion = { version = "0.5", features = ["async_tokio", "html_reports"] } diff --git a/packages/router/examples/simple_routes.rs b/packages/router/examples/simple_routes.rs index 3634f7df3..aac1e8658 100644 --- a/packages/router/examples/simple_routes.rs +++ b/packages/router/examples/simple_routes.rs @@ -2,6 +2,47 @@ use dioxus::prelude::*; use dioxus_router::prelude::*; use std::str::FromStr; +#[cfg(feature = "liveview")] +#[tokio::main] +async fn main() { + use axum::{extract::ws::WebSocketUpgrade, response::Html, routing::get, Router}; + + let listen_address: std::net::SocketAddr = ([127, 0, 0, 1], 3030).into(); + let view = dioxus_liveview::LiveViewPool::new(); + let app = Router::new() + .fallback(get(move || async move { + Html(format!( + r#" + + + +
+ {glue} + + "#, + glue = dioxus_liveview::interpreter_glue(&format!("ws://{listen_address}/ws")) + )) + })) + .route( + "/ws", + get(move |ws: WebSocketUpgrade| async move { + ws.on_upgrade(move |socket| async move { + _ = view + .launch(dioxus_liveview::axum_socket(socket), Root) + .await; + }) + }), + ); + + println!("Listening on http://{listen_address}"); + + axum::Server::bind(&listen_address.to_string().parse().unwrap()) + .serve(app.into_make_service()) + .await + .unwrap(); +} + +#[cfg(not(feature = "liveview"))] fn main() { #[cfg(not(target_arch = "wasm32"))] dioxus_desktop::launch(Root); @@ -10,21 +51,26 @@ fn main() { dioxus_web::launch(root); } +#[cfg(feature = "liveview")] #[component] fn Root(cx: Scope) -> Element { - render! { - Router:: {} - } + let history = LiveviewHistory::new(cx); + render! { Router:: { + config: || RouterConfig::default().history(history), + } } +} + +#[cfg(not(feature = "liveview"))] +#[component] +fn Root(cx: Scope) -> Element { + render! { Router:: {} } } #[component] fn UserFrame(cx: Scope, user_id: usize) -> Element { render! { - pre { - "UserFrame{{\n\tuser_id:{user_id}\n}}" - } - div { - background_color: "rgba(0,0,0,50%)", + pre { "UserFrame{{\n\tuser_id:{user_id}\n}}" } + div { background_color: "rgba(0,0,0,50%)", "children:" Outlet:: {} } @@ -88,6 +134,16 @@ fn Route3(cx: Scope, dynamic: String) -> Element { to: Route::Route2 { user_id: 8888 }, "hello world link" } + button { + disabled: !navigator.can_go_back(), + onclick: move |_| { navigator.go_back(); }, + "go back" + } + button { + disabled: !navigator.can_go_forward(), + onclick: move |_| { navigator.go_forward(); }, + "go forward" + } button { onclick: move |_| { navigator.push("https://www.google.com"); }, "google link" diff --git a/packages/router/src/components/history_buttons.rs b/packages/router/src/components/history_buttons.rs index eb62e9285..ae11ad276 100644 --- a/packages/router/src/components/history_buttons.rs +++ b/packages/router/src/components/history_buttons.rs @@ -142,7 +142,7 @@ pub fn GoForwardButton<'a>(cx: Scope<'a, HistoryButtonProps<'a>>) -> Element { } }; - let disabled = !router.can_go_back(); + let disabled = !router.can_go_forward(); render! { button { diff --git a/packages/router/src/contexts/router.rs b/packages/router/src/contexts/router.rs index b15956991..079640e61 100644 --- a/packages/router/src/contexts/router.rs +++ b/packages/router/src/contexts/router.rs @@ -232,7 +232,7 @@ impl RouterContext { IntoRoutable::FromStr(url) => { let parsed_route: NavigationTarget> = match self.route_from_str(&url) { Ok(route) => NavigationTarget::Internal(route), - Err(err) => NavigationTarget::External(err), + Err(_) => NavigationTarget::External(url), }; parsed_route } diff --git a/packages/router/src/history/liveview.rs b/packages/router/src/history/liveview.rs new file mode 100644 index 000000000..9ffb09c34 --- /dev/null +++ b/packages/router/src/history/liveview.rs @@ -0,0 +1,441 @@ +use super::HistoryProvider; +use crate::routable::Routable; +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; +use std::sync::{Mutex, RwLock}; +use std::{collections::BTreeMap, rc::Rc, str::FromStr, sync::Arc}; + +/// A [`HistoryProvider`] that evaluates history through JS. +pub struct LiveviewHistory +where + ::Err: std::fmt::Display, +{ + action_tx: tokio::sync::mpsc::UnboundedSender>, + timeline: Arc>>, + updater_callback: Arc>>, +} + +struct Timeline +where + ::Err: std::fmt::Display, +{ + current_index: usize, + routes: BTreeMap, +} + +#[derive(Serialize, Deserialize, Debug)] +struct State { + index: usize, +} + +#[derive(Serialize, Deserialize, Debug)] +struct Session +where + ::Err: std::fmt::Display, +{ + #[serde(with = "routes")] + routes: BTreeMap, + last_visited: usize, +} + +#[derive(Serialize, Deserialize)] +struct SessionStorage { + liveview: Option, +} + +enum Action { + GoBack, + GoForward, + Push(R), + Replace(R), + External(String), +} + +impl Timeline +where + ::Err: std::fmt::Display, +{ + fn new(initial_path: R) -> Self { + Self { + current_index: 0, + routes: BTreeMap::from([(0, initial_path)]), + } + } + + fn init( + &mut self, + route: R, + state: Option, + session: Option>, + depth: usize, + ) -> State { + if let Some(session) = session { + self.routes = session.routes; + if state.is_none() { + // top of stack + let last_visited = session.last_visited; + self.routes.retain(|&lhs, _| lhs <= last_visited); + } + }; + let state = match state { + Some(state) => { + self.current_index = state.index; + state + } + None => { + let index = depth - 1; + self.current_index = index; + State { index } + } + }; + self.routes.insert(state.index, route); + state + } + + fn update(&mut self, route: R, state: Option) -> State { + if let Some(state) = state { + self.current_index = state.index; + self.routes.insert(self.current_index, route); + state + } else { + self.push(route) + } + } + + fn push(&mut self, route: R) -> State { + // top of stack + let index = self.current_index + 1; + self.current_index = index; + self.routes.insert(index, route); + self.routes.retain(|&rhs, _| index >= rhs); + State { + index: self.current_index, + } + } + + fn replace(&mut self, route: R) -> State { + self.routes.insert(self.current_index, route); + State { + index: self.current_index, + } + } + + fn current_route(&self) -> &R { + &self.routes[&self.current_index] + } + + fn session(&self) -> Session { + Session { + routes: self.routes.clone(), + last_visited: self.current_index, + } + } +} + +impl LiveviewHistory +where + ::Err: std::fmt::Display, +{ + /// Create a [`LiveviewHistory`] in the given scope. + /// When using a [`LiveviewHistory`] in combination with use_eval, history must be untampered with. + /// + /// # Panics + /// + /// Panics if not in a Liveview context. + pub fn new(cx: &ScopeState) -> Self { + Self::new_with_initial_path( + cx, + "/".parse().unwrap_or_else(|err| { + panic!("index route does not exist:\n{}\n use LiveviewHistory::new_with_initial_path to set a custom path", err) + }), + ) + } + + /// Create a [`LiveviewHistory`] in the given scope, starting at `initial_path`. + /// When using a [`LiveviewHistory`] in combination with use_eval, history must be untampered with. + /// + /// # Panics + /// + /// Panics if not in a Liveview context. + pub fn new_with_initial_path(cx: &ScopeState, initial_path: R) -> Self { + let (action_tx, action_rx) = tokio::sync::mpsc::unbounded_channel::>(); + let action_rx = Arc::new(Mutex::new(action_rx)); + let timeline = Arc::new(Mutex::new(Timeline::new(initial_path))); + let updater_callback: Arc>> = + Arc::new(RwLock::new(Arc::new(|| {}))); + + let eval_provider = cx + .consume_context::>() + .expect("evaluator not provided"); + + let create_eval = Rc::new(move |script: &str| { + eval_provider + .new_evaluator(script.to_string()) + .map(UseEval::new) + }) as Rc Result>; + + // Listen to server actions + cx.push_future({ + let timeline = timeline.clone(); + let action_rx = action_rx.clone(); + let create_eval = create_eval.clone(); + async move { + let mut action_rx = action_rx.lock().expect("unpoisoned mutex"); + loop { + let eval = action_rx.recv().await.expect("sender to exist"); + let _ = match eval { + Action::GoBack => create_eval( + r#" + // this triggers a PopState event + history.back(); + "#, + ), + Action::GoForward => create_eval( + r#" + // this triggers a PopState event + history.forward(); + "#, + ), + Action::Push(route) => { + let mut timeline = timeline.lock().expect("unpoisoned mutex"); + let state = timeline.push(route.clone()); + let state = serde_json::to_string(&state).expect("serializable state"); + let session = serde_json::to_string(&timeline.session()) + .expect("serializable session"); + create_eval(&format!( + r#" + // this does not trigger a PopState event + history.pushState({state}, "", "{route}"); + sessionStorage.setItem("liveview", '{session}'); + "# + )) + } + Action::Replace(route) => { + let mut timeline = timeline.lock().expect("unpoisoned mutex"); + let state = timeline.replace(route.clone()); + let state = serde_json::to_string(&state).expect("serializable state"); + let session = serde_json::to_string(&timeline.session()) + .expect("serializable session"); + create_eval(&format!( + r#" + // this does not trigger a PopState event + history.replaceState({state}, "", "{route}"); + sessionStorage.setItem("liveview", '{session}'); + "# + )) + } + Action::External(url) => create_eval(&format!( + r#" + location.href = "{url}"; + "# + )), + }; + } + } + }); + + // Listen to browser actions + cx.push_future({ + let updater = updater_callback.clone(); + let timeline = timeline.clone(); + let create_eval = create_eval.clone(); + async move { + let popstate_eval = { + let init_eval = create_eval( + r#" + return [ + document.location.pathname + "?" + document.location.search + "\#" + document.location.hash, + history.state, + JSON.parse(sessionStorage.getItem("liveview")), + history.length, + ]; + "#, + ).expect("failed to load state").await.expect("serializable state"); + let (route, state, session, depth) = serde_json::from_value::<( + String, + Option, + Option>, + usize, + )>(init_eval).expect("serializable state"); + let Ok(route) = R::from_str(&route.to_string()) else { + return; + }; + let mut timeline = timeline.lock().expect("unpoisoned mutex"); + let state = timeline.init(route.clone(), state, session, depth); + let state = serde_json::to_string(&state).expect("serializable state"); + let session = serde_json::to_string(&timeline.session()) + .expect("serializable session"); + + // Call the updater callback + (updater.read().unwrap())(); + + create_eval(&format!(r#" + // this does not trigger a PopState event + history.replaceState({state}, "", "{route}"); + sessionStorage.setItem("liveview", '{session}'); + + window.addEventListener("popstate", (event) => {{ + dioxus.send([ + document.location.pathname + "?" + document.location.search + "\#" + document.location.hash, + event.state, + ]); + }}); + "#)).expect("failed to initialize popstate") + }; + + loop { + let event = match popstate_eval.recv().await { + Ok(event) => event, + Err(_) => continue, + }; + let (route, state) = serde_json::from_value::<(String, Option)>(event).expect("serializable state"); + let Ok(route) = R::from_str(&route.to_string()) else { + return; + }; + let mut timeline = timeline.lock().expect("unpoisoned mutex"); + let state = timeline.update(route.clone(), state); + let state = serde_json::to_string(&state).expect("serializable state"); + let session = serde_json::to_string(&timeline.session()) + .expect("serializable session"); + + let _ = create_eval(&format!( + r#" + // this does not trigger a PopState event + history.replaceState({state}, "", "{route}"); + sessionStorage.setItem("liveview", '{session}'); + "#)); + + // Call the updater callback + (updater.read().unwrap())(); + } + } + }); + + Self { + action_tx, + timeline, + updater_callback, + } + } +} + +impl HistoryProvider for LiveviewHistory +where + ::Err: std::fmt::Display, +{ + fn go_back(&mut self) { + let _ = self.action_tx.send(Action::GoBack); + } + + fn go_forward(&mut self) { + let _ = self.action_tx.send(Action::GoForward); + } + + fn push(&mut self, route: R) { + let _ = self.action_tx.send(Action::Push(route)); + } + + fn replace(&mut self, route: R) { + let _ = self.action_tx.send(Action::Replace(route)); + } + + fn external(&mut self, url: String) -> bool { + let _ = self.action_tx.send(Action::External(url)); + true + } + + fn current_route(&self) -> R { + let timeline = self.timeline.lock().expect("unpoisoned mutex"); + timeline.current_route().clone() + } + + fn can_go_back(&self) -> bool { + let timeline = self.timeline.lock().expect("unpoisoned mutex"); + // Check if the one before is contiguous (i.e., not an external page) + let visited_indices: Vec = timeline.routes.keys().cloned().collect(); + visited_indices + .iter() + .position(|&rhs| timeline.current_index == rhs) + .map_or(false, |index| { + index > 0 && visited_indices[index - 1] == timeline.current_index - 1 + }) + } + + fn can_go_forward(&self) -> bool { + let timeline = self.timeline.lock().expect("unpoisoned mutex"); + // Check if the one after is contiguous (i.e., not an external page) + let visited_indices: Vec = timeline.routes.keys().cloned().collect(); + visited_indices + .iter() + .rposition(|&rhs| timeline.current_index == rhs) + .map_or(false, |index| { + index < visited_indices.len() - 1 + && visited_indices[index + 1] == timeline.current_index + 1 + }) + } + + fn updater(&mut self, callback: Arc) { + let mut updater_callback = self.updater_callback.write().unwrap(); + *updater_callback = callback; + } +} + +mod routes { + use crate::prelude::Routable; + use core::str::FromStr; + use serde::de::{MapAccess, Visitor}; + use serde::{ser::SerializeMap, Deserializer, Serializer}; + use std::collections::BTreeMap; + + pub fn serialize(routes: &BTreeMap, serializer: S) -> Result + where + S: Serializer, + R: Routable, + { + let mut map = serializer.serialize_map(Some(routes.len()))?; + for (index, route) in routes.iter() { + map.serialize_entry(&index.to_string(), &route.to_string())?; + } + map.end() + } + + pub fn deserialize<'de, D, R>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + R: Routable, + ::Err: std::fmt::Display, + { + struct BTreeMapVisitor { + marker: std::marker::PhantomData, + } + + impl<'de, R> Visitor<'de> for BTreeMapVisitor + where + R: Routable, + ::Err: std::fmt::Display, + { + type Value = BTreeMap; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a map with indices and routable values") + } + + fn visit_map(self, mut map: M) -> Result + where + M: MapAccess<'de>, + { + let mut routes = BTreeMap::new(); + while let Some((index, route)) = map.next_entry::()? { + let index = index.parse::().map_err(serde::de::Error::custom)?; + let route = R::from_str(&route).map_err(serde::de::Error::custom)?; + routes.insert(index, route); + } + Ok(routes) + } + } + + deserializer.deserialize_map(BTreeMapVisitor { + marker: std::marker::PhantomData, + }) + } +} diff --git a/packages/router/src/history/mod.rs b/packages/router/src/history/mod.rs index e9668b979..cf7183366 100644 --- a/packages/router/src/history/mod.rs +++ b/packages/router/src/history/mod.rs @@ -22,6 +22,11 @@ pub use web::*; #[cfg(feature = "web")] pub(crate) mod web_history; +#[cfg(feature = "liveview")] +mod liveview; +#[cfg(feature = "liveview")] +pub use liveview::*; + // #[cfg(feature = "web")] // mod web_hash; // #[cfg(feature = "web")]