Bläddra i källkod

Implement LiveView Router Integration (#1505)

* Fix GoForwardButton calling can_go_back instead of can_go_forward

* Add first draft of LiveviewHistory

* Add external URL redirect

* Lock evaluator channel outside loop

* Add liveview to router examples

* fixup! Add liveview to router examples

* Communicate with liveview server on page load

* Add PopState event to Liveview routing

* Call updater callback from liveview history

* Add rudimentary PopState functionality to liveview router.

* Fix linter errors

* Refactor

* Fix navigator external redirection not working.

* Add go back and go forward buttons to router examples

* Finish functionality for timeline stack in liveview router

* Add docs to LiveviewHistory

* Replace Liveview history context attachment with constructor that takes context

* Fix go forward/backward history/future shuffle

* Support history across entire liveview session, if contiguous page jumps.

* Remove unnecessary bounds

* Add query and hash to location string

* Run rustfmt

* fix: Update server function docs link (#1489)

* liveview: Add `interpreter_glue_relative_uri (#1481)

* liveview: Add `interpreter_glue_relative_uri`

By utilizing `window.location.host` in the client-side JavaScript, we can easily derive the WebSocket URI from a relative path URI. This approach obviates the need for host address retrieval on the server side, unlike the method of serving glue code in liveview using `interpreter_glue`.

* liveview: Merge `.._relative_url` functionality

- Merged `.._relative_url` to current API `interpreter_glue`.
- Edit axum example to work with new feature.

* liveview: Fix clippy warning

* Rename modules to use snake_case (#1498)

* Change Scope into &ScopeState

* Move synchronization of state into router and make it opt-in

---------

Co-authored-by: Marc Espín <mespinsanz@gmail.com>
Co-authored-by: Seungwoo Kang <ki6080@gmail.com>
Co-authored-by: Leonard <tigerros.gh@gmail.com>
Co-authored-by: Evan Almloff <evanalmloff@gmail.com>
Emil Boman 1 år sedan
förälder
incheckning
f5bc1a9856

+ 5 - 0
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"] }
 

+ 64 - 8
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#"
+                    <!DOCTYPE html>
+                    <html>
+                        <head></head>
+                        <body><div id="main"></div></body>
+                        {glue}
+                    </html>
+                "#,
+                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::<Route> {}
-    }
+    let history = LiveviewHistory::new(cx);
+    render! { Router::<Route> {
+        config: || RouterConfig::default().history(history),
+    } }
+}
+
+#[cfg(not(feature = "liveview"))]
+#[component]
+fn Root(cx: Scope) -> Element {
+    render! { Router::<Route> {} }
 }
 
 #[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::<Route> {}
         }
@@ -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"

+ 1 - 1
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 {

+ 1 - 1
packages/router/src/contexts/router.rs

@@ -232,7 +232,7 @@ impl RouterContext {
             IntoRoutable::FromStr(url) => {
                 let parsed_route: NavigationTarget<Rc<dyn Any>> = match self.route_from_str(&url) {
                     Ok(route) => NavigationTarget::Internal(route),
-                    Err(err) => NavigationTarget::External(err),
+                    Err(_) => NavigationTarget::External(url),
                 };
                 parsed_route
             }

+ 441 - 0
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<R: Routable>
+where
+    <R as FromStr>::Err: std::fmt::Display,
+{
+    action_tx: tokio::sync::mpsc::UnboundedSender<Action<R>>,
+    timeline: Arc<Mutex<Timeline<R>>>,
+    updater_callback: Arc<RwLock<Arc<dyn Fn() + Send + Sync>>>,
+}
+
+struct Timeline<R: Routable>
+where
+    <R as FromStr>::Err: std::fmt::Display,
+{
+    current_index: usize,
+    routes: BTreeMap<usize, R>,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+struct State {
+    index: usize,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+struct Session<R: Routable>
+where
+    <R as FromStr>::Err: std::fmt::Display,
+{
+    #[serde(with = "routes")]
+    routes: BTreeMap<usize, R>,
+    last_visited: usize,
+}
+
+#[derive(Serialize, Deserialize)]
+struct SessionStorage {
+    liveview: Option<String>,
+}
+
+enum Action<R: Routable> {
+    GoBack,
+    GoForward,
+    Push(R),
+    Replace(R),
+    External(String),
+}
+
+impl<R: Routable> Timeline<R>
+where
+    <R as FromStr>::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<State>,
+        session: Option<Session<R>>,
+        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>) -> 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<R> {
+        Session {
+            routes: self.routes.clone(),
+            last_visited: self.current_index,
+        }
+    }
+}
+
+impl<R: Routable> LiveviewHistory<R>
+where
+    <R as FromStr>::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::<Action<R>>();
+        let action_rx = Arc::new(Mutex::new(action_rx));
+        let timeline = Arc::new(Mutex::new(Timeline::new(initial_path)));
+        let updater_callback: Arc<RwLock<Arc<dyn Fn() + Send + Sync>>> =
+            Arc::new(RwLock::new(Arc::new(|| {})));
+
+        let eval_provider = cx
+            .consume_context::<Rc<dyn EvalProvider>>()
+            .expect("evaluator not provided");
+
+        let create_eval = Rc::new(move |script: &str| {
+            eval_provider
+                .new_evaluator(script.to_string())
+                .map(UseEval::new)
+        }) as Rc<dyn Fn(&str) -> Result<UseEval, EvalError>>;
+
+        // 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<State>,
+                        Option<Session<R>>,
+                        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<State>)>(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<R: Routable> HistoryProvider<R> for LiveviewHistory<R>
+where
+    <R as FromStr>::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<usize> = 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<usize> = 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<dyn Fn() + Send + Sync>) {
+        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<S, R>(routes: &BTreeMap<usize, R>, serializer: S) -> Result<S::Ok, S::Error>
+    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<BTreeMap<usize, R>, D::Error>
+    where
+        D: Deserializer<'de>,
+        R: Routable,
+        <R as FromStr>::Err: std::fmt::Display,
+    {
+        struct BTreeMapVisitor<R> {
+            marker: std::marker::PhantomData<R>,
+        }
+
+        impl<'de, R> Visitor<'de> for BTreeMapVisitor<R>
+        where
+            R: Routable,
+            <R as FromStr>::Err: std::fmt::Display,
+        {
+            type Value = BTreeMap<usize, R>;
+
+            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
+                formatter.write_str("a map with indices and routable values")
+            }
+
+            fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
+            where
+                M: MapAccess<'de>,
+            {
+                let mut routes = BTreeMap::new();
+                while let Some((index, route)) = map.next_entry::<String, String>()? {
+                    let index = index.parse::<usize>().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,
+        })
+    }
+}

+ 5 - 0
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")]