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")]