From 81329ffa5ae17a182edb97e44ee7633235873c8e Mon Sep 17 00:00:00 2001 From: mobile-bungalow Date: Tue, 29 Oct 2024 20:56:11 -0700 Subject: [PATCH 01/21] Cargo.lock --- Cargo.lock | 76 +++++++++++++++++++++++++++--------------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 55daa9a8..b78cb4e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,9 +50,9 @@ checksum = "4aa90d7ce82d4be67b64039a3d588d38dbcc6736577de4a847025ce5b0c468d1" [[package]] name = "anstream" -version = "0.6.15" +version = "0.6.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" +checksum = "23a1e53f0f5d86382dafe1cf314783b2044280f406e7e1506368220ad11b1338" dependencies = [ "anstyle", "anstyle-parse", @@ -65,36 +65,36 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" +checksum = "8365de52b16c035ff4fcafe0092ba9390540e3e352870ac09933bebcaa2c8c56" [[package]] name = "anstyle-parse" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "anstyle-wincon" -version = "3.0.4" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" +checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" dependencies = [ "anstyle", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -436,9 +436,9 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "colorchoice" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" [[package]] name = "console_error_panic_hook" @@ -642,9 +642,9 @@ checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" [[package]] name = "fdeflate" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8090f921a24b04994d9929e204f50b498a33ea6ba559ffaa05e04f7ee7fb5ab" +checksum = "07c6f4c64c1d33a3111c4466f7365ebdcc37c5bd1ea0d62aae2e3d722aacbedb" dependencies = [ "simd-adler32", ] @@ -1005,9 +1005,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41296eb09f183ac68eec06e03cdbea2e759633d4067b2f6552fc2e009bcad08b" +checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" dependencies = [ "bytes", "futures-channel", @@ -1196,7 +1196,7 @@ checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" [[package]] name = "liveview-native-core" -version = "0.4.0-alpha-11" +version = "0.4.0-alpha-12" dependencies = [ "Inflector", "cranelift-entity", @@ -1224,7 +1224,7 @@ dependencies = [ [[package]] name = "liveview_native_core_wasm" -version = "0.4.0-alpha-11" +version = "0.4.0-alpha-12" dependencies = [ "console_error_panic_hook", "console_log", @@ -1299,9 +1299,9 @@ dependencies = [ [[package]] name = "minicov" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c71e683cd655513b99affab7d317deb690528255a0d5f717f1024093c12b169" +checksum = "def6d99771d7c499c26ad4d40eb6645eafd3a1553b35fc26ea5a489a45e82d9a" dependencies = [ "cc", "walkdir", @@ -1550,7 +1550,7 @@ dependencies = [ [[package]] name = "phoenix_channels_client" version = "0.9.1" -source = "git+https://github.com/liveview-native/phoenix-channels-client.git?branch=main#c275f4c197d7a50579a0aabd3e245892ac83c482" +source = "git+https://github.com/liveview-native/phoenix-channels-client.git?branch=main#50f56ff3a2ba4f8cb3bad89cf24c8669ecdf25dd" dependencies = [ "arc-swap", "atomic-take", @@ -1573,9 +1573,9 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" +checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" [[package]] name = "pin-utils" @@ -1789,9 +1789,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", @@ -1818,9 +1818,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "reqwest" -version = "0.12.8" +version = "0.12.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f713147fbe92361e52392c73b8c9e48c04c6625bce969ef54dc901e58e042a7b" +checksum = "a77c62af46e79de0a562e1a9849205ffcb7fc1238876e9bd743357570e04046f" dependencies = [ "base64", "bytes", @@ -1869,9 +1869,9 @@ checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustix" -version = "0.38.37" +version = "0.38.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" +checksum = "aa260229e6538e52293eeb577aabd09945a09d6d9cc0fc550ed7529056c2e32a" dependencies = [ "bitflags 2.6.0", "errno", @@ -1985,9 +1985,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.213" +version = "1.0.214" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ea7893ff5e2466df8d720bb615088341b295f849602c6956047f8f80f0e9bc1" +checksum = "f55c3193aca71c12ad7890f1785d2b73e1b9f63a0bbc353c08ef26fe03fc56b5" dependencies = [ "serde_derive", ] @@ -2005,9 +2005,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.213" +version = "1.0.214" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e85ad2009c50b58e87caa8cd6dac16bdf511bbfb7af6c33df902396aa480fa5" +checksum = "de523f781f095e28fa605cdce0f8307e451cc0fd14e2eb4cd2e98a355b147766" dependencies = [ "proc-macro2", "quote", @@ -2156,9 +2156,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.82" +version = "2.0.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83540f837a8afc019423a8edb95b52a8effe46957ee402287f4292fae35be021" +checksum = "5023162dfcd14ef8f32034d8bcd4cc5ddc61ef7a247c024a33e24e1f24d21b56" dependencies = [ "proc-macro2", "quote", @@ -2483,7 +2483,7 @@ dependencies = [ [[package]] name = "uniffi-bindgen" -version = "0.4.0-alpha-11" +version = "0.4.0-alpha-12" dependencies = [ "uniffi", ] From 5376d180d3a764aa98224cd5541e72ab6bb23284 Mon Sep 17 00:00:00 2001 From: mobile-bungalow Date: Mon, 2 Dec 2024 12:39:43 -0800 Subject: [PATCH 02/21] bump to main --- crates/core/src/live_socket/navigation/ffi.rs | 233 +++++++++++++ crates/core/src/live_socket/navigation/mod.rs | 275 ++++++++++++++++ crates/core/src/live_socket/protocol.rs | 0 .../core/src/live_socket/tests/navigation.rs | 307 ++++++++++++++++++ .../lib/test_server_web/live/nav.ex | 53 +++ 5 files changed, 868 insertions(+) create mode 100644 crates/core/src/live_socket/navigation/ffi.rs create mode 100644 crates/core/src/live_socket/navigation/mod.rs create mode 100644 crates/core/src/live_socket/protocol.rs create mode 100644 crates/core/src/live_socket/tests/navigation.rs create mode 100644 tests/support/test_server/lib/test_server_web/live/nav.ex diff --git a/crates/core/src/live_socket/navigation/ffi.rs b/crates/core/src/live_socket/navigation/ffi.rs new file mode 100644 index 00000000..ce0b3dcb --- /dev/null +++ b/crates/core/src/live_socket/navigation/ffi.rs @@ -0,0 +1,233 @@ +//! # FFI Navigation Types +//! +//! Types and utilities for interacting with the navigation API for the FFI api consumers. +use phoenix_channels_client::Socket; +use reqwest::Url; + +pub type HistoryId = u64; + +#[uniffi::export(callback_interface)] +pub trait NavEventHandler: Send + Sync { + /// This callback instruments events that occur when your user navigates to a + /// new view. You can add serialized metadata to these events as a byte buffer + /// through the [NavOptions] object. + fn handle_event(&self, event: NavEvent) -> HandlerResponse; +} + +/// User emitted response from [NavEventHandler::handle_event]. +/// Determines whether or not the default navigation action is taken. +#[derive(uniffi::Enum, Clone, Debug, PartialEq, Default)] +pub enum HandlerResponse { + #[default] + /// Return this to proceed as normal. + Default, + /// Return this to cancel the navigation before it occurs. + PreventDefault, +} + +#[derive(uniffi::Enum, Clone, Debug, PartialEq)] +pub enum NavEventType { + /// Pushing a new event onto the history stack + Push, + /// Replacing the most recent event on the history stack + Replace, + /// Reloading the view in place + Reload, + /// Skipping multiple items on the history stack, leaving them in tact. + Traverse, +} + +#[derive(uniffi::Record, Clone, Debug, PartialEq)] +pub struct NavHistoryEntry { + /// The target url. + pub url: String, + /// Unique id for this piece of nav entry state. + pub id: HistoryId, + /// state passed in by the user, to be passed in to the navigation event callback. + pub state: Option>, +} + +/// An event emitted when the user navigates between views. +#[derive(uniffi::Record, Clone, Debug, PartialEq)] +pub struct NavEvent { + /// The type of event being emitted. + pub event: NavEventType, + /// True if from and to point to the same path. + pub same_document: bool, + /// The previous location of the page, if there was one. + pub from: Option, + /// Destination URL. + pub to: NavHistoryEntry, + /// Additional user provided metadata handed to the event handler. + pub info: Option>, +} + +/// An action taken with respect to the history stack +/// when [NavCtx::navigate] is executed. defaults to +/// Push behavior. +#[derive(uniffi::Enum, Default, Clone)] +pub enum NavAction { + /// Push the navigation event onto the history stack. + #[default] + Push, + /// Replace the current top of the history stack with this navigation event. + Replace, +} + +/// Options for calls to [NavCtx::navigate] +#[derive(Default, uniffi::Record)] +pub struct NavOptions { + #[uniffi(default = None)] + pub action: Option, + #[uniffi(default = None)] + pub extra_event_info: Option>, + #[uniffi(default = None)] + pub state: Option>, +} + +impl NavEvent { + pub fn new( + event: NavEventType, + to: NavHistoryEntry, + from: Option, + info: Option>, + ) -> Self { + let new_url = Url::parse(&to.url).ok(); + let old_url = from.as_ref().and_then(|dest| Url::parse(&dest.url).ok()); + + let same_document = old_url + .zip(new_url) + .is_some_and(|(old, new)| old.path() == new.path()); + + NavEvent { + event, + same_document, + from, + to, + info, + } + } +} + +use crate::live_socket::socket::SessionData; + +use super::{super::error::LiveSocketError, LiveSocket, NavCtx}; + +impl LiveSocket { + /// Tries to navigate to the current item in the NavCtx. + /// changing state in one fell swoop if initialilization succeeds + async fn try_nav(&self) -> Result<(), LiveSocketError> { + let current = self + .current() + .ok_or(LiveSocketError::NavigationImpossible)?; + + let url = Url::parse(¤t.url)?; + + let format = self.session_data.try_lock()?.format.clone(); + let options = self.session_data.try_lock()?.connect_opts.clone(); + + let session_data = SessionData::request(&url, &format, options).await?; + let websocket_url = session_data.get_live_socket_url()?; + let socket = Socket::spawn(websocket_url, Some(session_data.cookies.clone())).await?; + + self.socket() + .shutdown() + .await + .map_err(|_| LiveSocketError::DisconnectionError)?; + + *self.socket.try_lock()? = socket; + *self.session_data.try_lock()? = session_data; + + Ok(()) + } + + /// calls [Self::try_nav] rolling back to a previous navigation state on failure. + async fn try_nav_outer(&self, nav_action: F) -> Result + where + F: FnOnce(&mut NavCtx) -> Option, + { + let new_id = { + let mut ctx = self.navigation_ctx.lock().expect("lock poison"); + nav_action(&mut ctx) + }; + + let Some(new_id) = new_id else { + return Err(LiveSocketError::NavigationImpossible); + }; + + match self.try_nav().await { + Ok(()) => Ok(new_id), + Err(e) => Err(e), + } + } +} + +#[cfg_attr(not(target_family = "wasm"), uniffi::export(async_runtime = "tokio"))] +impl LiveSocket { + pub async fn navigate( + &self, + url: String, + opts: NavOptions, + ) -> Result { + let url = Url::parse(&url)?; + self.try_nav_outer(|ctx| ctx.navigate(url, opts, true)) + .await + } + + pub async fn reload(&self, info: Option>) -> Result { + self.try_nav_outer(|ctx| ctx.reload(info, true)).await + } + + pub async fn back(&self, info: Option>) -> Result { + self.try_nav_outer(|ctx| ctx.back(info, true)).await + } + + pub async fn forward(&self, info: Option>) -> Result { + self.try_nav_outer(|ctx| ctx.forward(info, true)).await + } + + pub async fn traverse_to( + &self, + id: HistoryId, + info: Option>, + ) -> Result { + self.try_nav_outer(|ctx| ctx.traverse_to(id, info, true)) + .await + } + + /// Returns whether navigation backward in history is possible. + pub fn can_go_back(&self) -> bool { + let nav_ctx = self.navigation_ctx.lock().expect("lock poison"); + nav_ctx.can_go_back() + } + + /// Returns whether navigation forward in history is possible. + pub fn can_go_forward(&self) -> bool { + let nav_ctx = self.navigation_ctx.lock().expect("lock poison"); + nav_ctx.can_go_forward() + } + + /// Returns whether navigation to the specified history entry ID is possible. + pub fn can_traverse_to(&self, id: HistoryId) -> bool { + let nav_ctx = self.navigation_ctx.lock().expect("lock poison"); + nav_ctx.can_traverse_to(id) + } + + /// Returns a list of all history entries in traversal sequence order. + pub fn get_entries(&self) -> Vec { + let nav_ctx = self.navigation_ctx.lock().expect("lock poison"); + nav_ctx.entries() + } + + /// Returns the current history entry, if one exists. + pub fn current(&self) -> Option { + let nav_ctx = self.navigation_ctx.lock().expect("lock poison"); + nav_ctx.current() + } + + /// Sets the handler for navigation events. + pub fn set_event_handler(&self, handler: Box) { + let mut nav_ctx = self.navigation_ctx.lock().expect("lock poison"); + nav_ctx.set_event_handler(handler.into()) + } +} diff --git a/crates/core/src/live_socket/navigation/mod.rs b/crates/core/src/live_socket/navigation/mod.rs new file mode 100644 index 00000000..9aeda45b --- /dev/null +++ b/crates/core/src/live_socket/navigation/mod.rs @@ -0,0 +1,275 @@ +mod ffi; + +use super::socket::LiveSocket; +pub use ffi::*; +use reqwest::Url; +use std::sync::Arc; + +#[derive(Clone, Default)] +struct HandlerInternal(pub Option>); + +impl std::fmt::Debug for HandlerInternal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.0.is_some() { + write!(f, "Handler Active")?; + } else { + write!(f, "No Handler Present")?; + }; + Ok(()) + } +} + +/// The internal navigation context. +/// handles the history state of the visited views. +#[derive(Debug, Clone, Default)] +pub struct NavCtx { + /// Previously visited views + history: Vec, + /// Views that are "forward" in history + future: Vec, + /// monotonically increasing ID for `NavHistoryEntry` + id_source: HistoryId, + /// user provided callback + navigation_event_handler: HandlerInternal, +} + +impl NavHistoryEntry { + pub fn new(url: Url, id: HistoryId, state: Option>) -> Self { + Self { + url: url.to_string(), + id, + state, + } + } +} + +impl NavCtx { + /// Navigate to `url` with behavior and metadata specified in `opts`. + /// Returns the current history ID if changed + pub fn navigate(&mut self, url: Url, opts: NavOptions, emit_event: bool) -> Option { + let action = opts.action.clone(); + let next_dest = self.speculative_next_dest(&url, opts.state.clone()); + let next_id = next_dest.id; + + let event = { + let new_dest = next_dest.clone(); + let old_dest = self.current(); + let event = match opts.action { + Some(NavAction::Replace) => NavEventType::Replace, + _ => NavEventType::Push, + }; + + NavEvent::new(event, new_dest, old_dest, opts.extra_event_info) + }; + + match self.handle_event(event, emit_event) { + HandlerResponse::Default => {} + HandlerResponse::PreventDefault => return None, + }; + + match action { + Some(NavAction::Replace) => self.replace_entry(next_dest), + None | Some(NavAction::Push) => self.push_entry(next_dest), + } + + // successful navigation invalidates previously coalesced state from + // calls to `back` + self.future.clear(); + Some(next_id) + } + + // Returns true if the navigator can go back one entry. + pub fn can_go_back(&self) -> bool { + self.history.len() >= 2 + } + + // Returns true if the navigator can go forward one entry. + pub fn can_go_forward(&self) -> bool { + !self.future.is_empty() + } + + // Returns true if the `id` is tracked in the navigation context. + pub fn can_traverse_to(&self, id: HistoryId) -> bool { + let hist = self.history.iter().find(|ent| ent.id == id); + let fut = self.future.iter().find(|ent| ent.id == id); + hist.or(fut).is_some() + } + + // Returns all of the tracked history entries by cloning them. + // They are in traversal sequence order, with no guarantees about + // the position of the current entry. + pub fn entries(&self) -> Vec { + self.history + .iter() + .chain(self.future.iter().rev()) + .cloned() + .collect() + } + + /// Calls the handler for reload events + pub fn reload(&mut self, info: Option>, emit_event: bool) -> Option { + let current = self.current()?; + let id = current.id; + + let event = NavEvent::new(NavEventType::Reload, current.clone(), current.into(), info); + + match self.handle_event(event, emit_event) { + HandlerResponse::Default => {} + HandlerResponse::PreventDefault => return None, + }; + + Some(id) + } + + /// Navigates back one step in the stack, returning the id of the new + /// current entry if successful. + /// This function fails if there is no current + /// page or if there are no items in history and returns [None]. + pub fn back(&mut self, info: Option>, emit_event: bool) -> Option { + if !self.can_go_back() { + log::warn!("Attempted `back` navigation without at minimum two entries."); + return None; + } + + let previous = self.current()?; + + let next = self.history[self.history.len() - 2].clone(); + + let event = { + let new_dest = next.clone(); + let old_dest = previous.clone(); + NavEvent::new(NavEventType::Push, new_dest, Some(old_dest), info) + }; + + match self.handle_event(event, emit_event) { + HandlerResponse::Default => { + let previous = self.history.pop()?; + let out = Some(next.id); + self.future.push(previous); + out + } + HandlerResponse::PreventDefault => None, + } + } + + /// Navigate one step forward, fails if there is not at least one + /// item in the history and future stacks. + pub fn forward(&mut self, info: Option>, emit_event: bool) -> Option { + if !self.can_go_forward() { + log::warn!( + "Attempted `future` navigation with an no current location or no next entry." + ); + return None; + } + + let next = self.future.last().cloned()?; + let previous = self.current(); + + let event = NavEvent::new(NavEventType::Push, next, previous, info); + + match self.handle_event(event, emit_event) { + HandlerResponse::Default => { + let next = self.future.pop()?; + let out = Some(next.id); + self.push_entry(next); + out + } + HandlerResponse::PreventDefault => None, + } + } + + pub fn traverse_to( + &mut self, + id: HistoryId, + info: Option>, + emit_event: bool, + ) -> Option { + if !self.can_traverse_to(id) { + log::warn!("Attempted to traverse to an untracked ID!"); + return None; + } + + let old_dest = self.current()?; + let in_hist = self.history.iter().position(|ent| ent.id == id); + if let Some(entry) = in_hist { + let new_dest = self.history[entry].clone(); + + let event = NavEvent::new(NavEventType::Traverse, new_dest, old_dest.into(), info); + + match self.handle_event(event, emit_event) { + HandlerResponse::Default => {} + HandlerResponse::PreventDefault => return None, + }; + + // All entries except the target + let ext = self.history.drain(entry + 1..); + self.future.extend(ext.rev()); + return Some(id); + } + + let in_fut = self.future.iter().position(|ent| ent.id == id); + if let Some(entry) = in_fut { + let new_dest = self.future[entry].clone(); + + let event = NavEvent::new(NavEventType::Traverse, new_dest, old_dest.into(), info); + + match self.handle_event(event, emit_event) { + HandlerResponse::Default => {} + HandlerResponse::PreventDefault => return None, + }; + + // All entries including the target, which will be at the front. + let ext = self.future.drain(entry..); + self.history.extend(ext.rev()); + return Some(id); + } + + None + } + + /// Returns the current history entry and state + pub fn current(&self) -> Option { + self.history.last().cloned() + } + + fn replace_entry(&mut self, history_entry: NavHistoryEntry) { + if let Some(last) = self.history.last_mut() { + self.id_source += 1; + + *last = history_entry + } else { + self.push_entry(history_entry) + } + } + + fn push_entry(&mut self, history_entry: NavHistoryEntry) { + self.id_source += 1; + self.history.push(history_entry); + } + + pub fn set_event_handler(&mut self, handler: Arc) { + self.navigation_event_handler.0 = Some(handler) + } + + pub fn handle_event(&mut self, event: NavEvent, emit_event: bool) -> HandlerResponse { + if !emit_event { + return HandlerResponse::Default; + } + + if let Some(handler) = self.navigation_event_handler.0.as_ref() { + handler.handle_event(event) + } else { + HandlerResponse::Default + } + } + + /// create a new destination if one would be added to history, this includes + /// the next unique ID that would be issued. + fn speculative_next_dest(&self, url: &Url, state: Option>) -> NavHistoryEntry { + NavHistoryEntry { + id: self.id_source + 1, + url: url.to_string(), + state, + } + } +} diff --git a/crates/core/src/live_socket/protocol.rs b/crates/core/src/live_socket/protocol.rs new file mode 100644 index 00000000..e69de29b diff --git a/crates/core/src/live_socket/tests/navigation.rs b/crates/core/src/live_socket/tests/navigation.rs new file mode 100644 index 00000000..25507445 --- /dev/null +++ b/crates/core/src/live_socket/tests/navigation.rs @@ -0,0 +1,307 @@ +use std::sync::{Arc, Mutex}; + +use super::assert_doc_eq; +use crate::live_socket::navigation::*; +use crate::live_socket::LiveSocket; +use pretty_assertions::assert_eq; +use reqwest::Url; +use serde::{Deserialize, Serialize}; + +// Mock event handler used to validate the internal +// navigation objects state. +pub struct NavigationInspector { + last_event: Mutex>, +} + +#[derive(Serialize, Deserialize)] +pub struct EventMetadata { + prevent_default: bool, +} + +#[derive(Serialize, Deserialize)] +pub struct HistoryState { + name: String, +} + +impl NavEventHandler for NavigationInspector { + fn handle_event(&self, event: NavEvent) -> HandlerResponse { + *self.last_event.lock().expect("Lock poisoned!") = Some(event); + HandlerResponse::Default + } +} + +impl NavigationInspector { + pub fn new() -> Self { + Self { + last_event: None.into(), + } + } + + pub fn last_event(&self) -> Option { + self.last_event.lock().expect("Lock poisoned!").clone() + } +} + +impl NavEvent { + // utility function so I can sugar out boiler plate code in tests. + fn empty() -> Self { + Self { + to: NavHistoryEntry { + url: String::new(), + id: 0, + state: None, + }, + event: NavEventType::Push, + same_document: false, + from: None, + info: None, + } + } +} + +#[test] +fn basic_internal_nav() { + let handler = Arc::new(NavigationInspector::new()); + let mut ctx = NavCtx::default(); + ctx.set_event_handler(handler.clone()); + + // simple push nav + let url_str = "https://www.website.com/live"; + let url = Url::parse(url_str).expect("URL failed to parse"); + ctx.navigate(url, NavOptions::default(), true); + + assert_eq!( + NavEvent { + event: NavEventType::Push, + to: NavHistoryEntry { + state: None, + id: 1, + url: url_str.to_string(), + }, + ..NavEvent::empty() + }, + handler.last_event().expect("Missing Event") + ); +} + +#[test] +fn basic_internal_navigate_back() { + let handler = Arc::new(NavigationInspector::new()); + let mut ctx = NavCtx::default(); + ctx.set_event_handler(handler.clone()); + + // initial page + let first_url_str = "https://www.website.com/first"; + let url = Url::parse(first_url_str).expect("URL failed to parse"); + ctx.navigate(url, NavOptions::default(), true); + + // second page + let url_str = "https://www.website.com/second"; + let url = Url::parse(url_str).expect("URL failed to parse"); + ctx.navigate(url, NavOptions::default(), true) + .expect("Failed."); + + assert_eq!( + NavEvent { + to: NavHistoryEntry { + state: None, + id: 2, + url: url_str.to_string(), + }, + from: NavHistoryEntry { + state: None, + id: 1, + url: first_url_str.to_string(), + } + .into(), + ..NavEvent::empty() + }, + handler.last_event().expect("Missing Event") + ); + + //go back one view + ctx.back(None, true).expect("Failed Back."); + + assert_eq!( + NavEvent { + to: NavHistoryEntry { + state: None, + id: 1, + url: first_url_str.to_string(), + }, + from: NavHistoryEntry { + state: None, + id: 2, + url: url_str.to_string(), + } + .into(), + ..NavEvent::empty() + }, + handler.last_event().expect("Missing Event") + ); +} + +#[test] +fn test_navigation_with_state() { + let handler = Arc::new(NavigationInspector::new()); + let mut ctx = NavCtx::default(); + ctx.set_event_handler(handler.clone()); + + let url = Url::parse("https://example.com").expect("parse"); + let state = vec![1, 2, 3]; + let info = vec![4, 5, 6]; + + let opts = NavOptions { + state: Some(state.clone()), + extra_event_info: Some(info.clone()), + ..Default::default() + }; + + let id = ctx.navigate(url.clone(), opts, true).expect("nav"); + + let last_ev = handler.last_event().expect("no event."); + assert_eq!(last_ev.info, Some(info)); + + let current = ctx.current().expect("current"); + assert_eq!(current.id, id); + assert_eq!(current.state, Some(state)); +} + +#[test] +fn test_navigation_stack() { + let mut ctx = NavCtx::default(); + let first = Url::parse("https://example.com/first").expect("parse first"); + let second = Url::parse("https://example.com/second").expect("parse second"); + let third = Url::parse("https://example.com/third").expect("parse third"); + + let id1 = ctx + .navigate(first.clone(), NavOptions::default(), true) + .expect("nav first"); + let id2 = ctx + .navigate(second.clone(), NavOptions::default(), true) + .expect("nav second"); + let id3 = ctx + .navigate(third.clone(), NavOptions::default(), true) + .expect("nav third"); + + assert_eq!(ctx.current().expect("current").url, third.to_string()); + + let prev_id = ctx.back(None, true).expect("back"); + assert_eq!(prev_id, id2); + assert_eq!(ctx.current().expect("current").url, second.to_string()); + assert_eq!(ctx.entries().len(), 3); + + let next_id = ctx.forward(None, true).expect("forward"); + assert_eq!(next_id, id3); + assert_eq!(ctx.current().expect("current").url, third.to_string()); + assert_eq!(ctx.entries().len(), 3); + + ctx.traverse_to(id1, None, true) + .expect("Failed to traverse"); + assert_eq!(ctx.current().expect("current").url, first.to_string()); + assert_eq!(ctx.entries().len(), 3); + + ctx.traverse_to(id3, None, true) + .expect("Failed to traverse"); + assert_eq!(ctx.current().expect("current").url, third.to_string()); + assert_eq!(ctx.entries().len(), 3); +} + +#[cfg(target_os = "android")] +const HOST: &str = "10.0.2.2:4001"; + +#[cfg(not(target_os = "android"))] +const HOST: &str = "127.0.0.1:4001"; + +#[test] +fn test_navigation_rollback_forward() { + let mut ctx = NavCtx::default(); + let first = Url::parse("https://example.com/first").expect("parse first"); + let second = Url::parse("https://example.com/second").expect("parse second"); + + let id1 = ctx + .navigate(first.clone(), NavOptions::default(), true) + .expect("nav first"); + + let id2 = ctx + .navigate(second.clone(), NavOptions::default(), true) + .expect("nav second"); + + ctx.back(None, true).expect("back"); + assert_eq!(ctx.current().expect("current").id, id1); + + ctx.forward(None, true).expect("forward"); + assert_eq!(ctx.current().expect("current").id, id2); +} + +#[tokio::test] +async fn basic_nav_flow() { + let _ = env_logger::builder() + .parse_default_env() + .is_test(true) + .try_init(); + + let first = "first_page"; + let second = "second_page"; + let url = format!("http://{HOST}/nav/{first}"); + + let live_socket = LiveSocket::new(url.to_string(), "swiftui".into(), Default::default()) + .await + .expect("Failed to get liveview socket"); + + let live_channel = live_socket + .join_liveview_channel(None, None) + .await + .expect("Failed to join channel"); + + let join_doc = live_channel + .join_document() + .expect("Failed to render join payload"); + + let expected = r#" + + + + first_page + + + + NEXT + + + +"#; + + assert_doc_eq!(expected, join_doc.to_string()); + + let url = format!("http://{HOST}/nav/{second}"); + let _id = live_socket + .navigate(url, Default::default()) + .await + .expect("navigate"); + + let live_channel = live_socket + .join_liveview_channel(None, None) + .await + .expect("Failed to join channel"); + + let join_doc = live_channel + .join_document() + .expect("Failed to render join payload"); + + let expected = r#" + + + + second_page + + + + NEXT + + + +"#; + + assert_doc_eq!(expected, join_doc.to_string()); +} diff --git a/tests/support/test_server/lib/test_server_web/live/nav.ex b/tests/support/test_server/lib/test_server_web/live/nav.ex new file mode 100644 index 00000000..19da8790 --- /dev/null +++ b/tests/support/test_server/lib/test_server_web/live/nav.ex @@ -0,0 +1,53 @@ +defmodule TestServerWeb.NavLive do + use TestServerWeb, :live_view + use TestServerNative, :live_view + + def mount(%{"dynamic" => path}, _session, socket) do + {:ok, assign(socket, path: path)} + end + + def render(assigns) do + ~H""" +

+ <%= @path %> + <.link href={~p"/nav/next"}>Next +

+ """ + end +end + +defmodule TestServerWeb.NavLive.Jetpack do + use TestServerNative, [:render_component, format: :jetpack] + + def render(assigns, _) do + ~LVN""" + + + <%= @path %> + + Next + + + + """ + end +end + +defmodule TestServerWeb.NavLive.SwiftUI do + use TestServerNative, [:render_component, format: :swiftui] + + def render(assigns, _interface) do + ~LVN""" + + + <%= @path %> + + + + NEXT + + + + """ + end +end From f5d881c4d2e390f9ef9239a0f322efe0f8799980 Mon Sep 17 00:00:00 2001 From: mobile-bungalow Date: Mon, 2 Dec 2024 14:56:02 -0800 Subject: [PATCH 03/21] merge diff now ends when teh channel shuts down --- Cargo.lock | 1 + crates/core/Cargo.toml | 23 ++-- crates/core/src/dom/ffi.rs | 12 +- crates/core/src/live_socket/channel.rs | 59 ++++++--- crates/core/src/live_socket/tests/mod.rs | 117 ++++++++++++++++-- .../core/src/live_socket/tests/navigation.rs | 1 - 6 files changed, 169 insertions(+), 44 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b562d7a1..dfe13659 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1202,6 +1202,7 @@ dependencies = [ "cranelift-entity", "env_logger", "fixedbitset", + "futures", "fxhash", "html5gum", "image", diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 9b3be592..b85e36b6 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -15,20 +15,12 @@ publish.workspace = true [lib] -crate-type = [ - "staticlib", - "rlib", - "cdylib" -] +crate-type = ["staticlib", "rlib", "cdylib"] name = "liveview_native_core" [features] default = ["liveview-channels-tls"] -liveview-channels = [ - "phoenix_channels_client", - "reqwest", - "uniffi/tokio", -] +liveview-channels = ["phoenix_channels_client", "reqwest", "uniffi/tokio"] liveview-channels-tls = [ "liveview-channels", "reqwest/native-tls-vendored", @@ -38,18 +30,21 @@ liveview-channels-tls = [ # This is for support of phoenix-channnels-client in for wasm. browser = [ -#"liveview-channels", -#"phoenix_channels_client/browser", + #"liveview-channels", + #"phoenix_channels_client/browser", ] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +futures = "0.3.31" cranelift-entity = { version = "0.114" } fixedbitset = { version = "0.4" } fxhash = { version = "0.2" } html5gum = { git = "https://github.com/liveviewnative/html5gum", branch = "lvn" } -petgraph = { version = "0.6", default-features = false, features = ["graphmap"] } +petgraph = { version = "0.6", default-features = false, features = [ + "graphmap", +] } serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0" } smallstr = { version = "0.3", features = ["union"] } @@ -69,7 +64,7 @@ Inflector = "0.11" paste = { version = "1.0" } pretty_assertions = { version = "1.4.0" } text-diff = { version = "0.4.0" } -uniffi = { workspace = true, features = ["bindgen-tests", "tokio"]} +uniffi = { workspace = true, features = ["bindgen-tests", "tokio"] } tokio = { version = "1.41", features = ["full"] } env_logger = "0.11.1" diff --git a/crates/core/src/dom/ffi.rs b/crates/core/src/dom/ffi.rs index 9feb8913..c6741e61 100644 --- a/crates/core/src/dom/ffi.rs +++ b/crates/core/src/dom/ffi.rs @@ -3,6 +3,8 @@ use std::{ sync::{Arc, Mutex}, }; +use phoenix_channels_client::JSON; + use super::ChangeType; pub use super::{ attribute::Attribute, @@ -62,9 +64,8 @@ impl Document { self.inner.lock().expect("lock poisoned!").event_callback = Some(Arc::from(handler)); } - pub fn merge_fragment_json(&self, json: &str) -> Result<(), RenderError> { - let json = serde_json::from_str(json)?; - + pub fn merge_fragment_json_unserialized(&self, json: JSON) -> Result<(), RenderError> { + let json = serde_json::Value::from(json); let results = self .inner .lock() @@ -101,6 +102,11 @@ impl Document { Ok(()) } + pub fn merge_fragment_json(&self, json: &str) -> Result<(), RenderError> { + let json = serde_json::from_str(json)?; + self.merge_fragment_json_unserialized(JSON::from(&json)) + } + pub fn next_upload_id(&self) -> u64 { self.inner.lock().expect("lock poisoned!").next_upload_id() } diff --git a/crates/core/src/live_socket/channel.rs b/crates/core/src/live_socket/channel.rs index ea5cdbc2..faae00eb 100644 --- a/crates/core/src/live_socket/channel.rs +++ b/crates/core/src/live_socket/channel.rs @@ -1,3 +1,5 @@ +use futures::{future::FutureExt, pin_mut, select}; + use std::{sync::Arc, time::Duration}; use super::{LiveSocketError, UploadConfig, UploadError}; @@ -143,28 +145,51 @@ impl LiveChannel { // TODO: This should probably take the event closure to send changes back to swift/kotlin let document = self.document.clone(); let events = self.channel.events(); + let statuses = self.channel.statuses(); loop { - let event = events.event().await?; - match event.event { - Event::Phoenix { phoenix } => { - error!("Phoenix Event for {phoenix:?} is unimplemented"); - } - Event::User { user } => { - if user == "diff" { - let Payload::JSONPayload { json } = event.payload else { - error!("Diff was not json!"); - continue; - }; - debug!("PAYLOAD: {json:?}"); - // This function merges and uses the event handler set in `set_event_handler` - // which will call back into the Swift/Kotlin. - document.merge_fragment_json(&json.to_string())?; - } - } + let event = events.event().fuse(); + let status = statuses.status().fuse(); + + pin_mut!(event, status); + + select! { + e = event => { + let e = e?; + match e.event { + Event::Phoenix { phoenix } => { + error!("Phoenix Event for {phoenix:?} is unimplemented"); + } + Event::User { user } => { + if user == "diff" { + let Payload::JSONPayload { json } = e.payload else { + error!("Diff was not json!"); + continue; + }; + debug!("PAYLOAD: {json:?}"); + // This function merges and uses the event handler set in `set_event_handler` + // which will call back into the Swift/Kotlin. + document.merge_fragment_json(&json.to_string())?; + } + } + }; + } + s = status => { + match s? { + phoenix_channels_client::ChannelStatus::Left => return Ok(()), + phoenix_channels_client::ChannelStatus::ShutDown => return Ok(()), + _ => {}, + } + } }; } } + /// Rejoin the channel + pub async fn rejoin(&self) -> Result<(), LiveSocketError> { + self.channel().join(self.timeout).await?; + Ok(()) + } + pub fn join_payload(&self) -> Payload { self.join_payload.clone() } diff --git a/crates/core/src/live_socket/tests/mod.rs b/crates/core/src/live_socket/tests/mod.rs index 76b3bc88..42a881f3 100644 --- a/crates/core/src/live_socket/tests/mod.rs +++ b/crates/core/src/live_socket/tests/mod.rs @@ -6,14 +6,10 @@ mod navigation; mod streaming; mod upload; -#[cfg(target_os = "android")] -const HOST: &str = "10.0.2.2:4001"; - -#[cfg(not(target_os = "android"))] -const HOST: &str = "127.0.0.1:4001"; - +use phoenix_channels_client::ChannelStatus; use pretty_assertions::assert_eq; +/// serializes two documents so the formatting matches before diffing. macro_rules! assert_doc_eq { ($gold:expr, $test:expr) => {{ use crate::dom::Document; @@ -25,6 +21,12 @@ macro_rules! assert_doc_eq { pub(crate) use assert_doc_eq; +#[cfg(target_os = "android")] +const HOST: &str = "10.0.2.2:4001"; + +#[cfg(not(target_os = "android"))] +const HOST: &str = "127.0.0.1:4001"; + #[tokio::test] async fn join_live_view() { let _ = env_logger::builder() @@ -49,9 +51,8 @@ async fn join_live_view() { let join_doc = live_channel .join_document() .expect("Failed to render join payload"); - let rendered = format!("{}", join_doc); - let expected = r#" - + let rendered = format!("{}", join_doc.to_string()); + let expected = r#" Hello SwiftUI! @@ -65,6 +66,104 @@ async fn join_live_view() { .expect("Failed to join channel"); } +#[tokio::test] +async fn channels_keep_listening_for_diffs_on_reconnect() { + let _ = env_logger::builder() + .parse_default_env() + .is_test(true) + .try_init(); + + let url = format!("http://{HOST}/hello"); + + let live_socket = LiveSocket::new(url.to_string(), "swiftui".into(), Default::default()) + .await + .expect("Failed to get liveview socket"); + + let live_channel = live_socket + .join_liveview_channel(None, None) + .await + .expect("Failed to join channel"); + + let live_channel = std::sync::Arc::new(live_channel); + + let chan_clone = live_channel.clone(); + + let handle = tokio::spawn(async move { + chan_clone + .merge_diffs() + .await + .expect("Failed to merge diffs"); + }); + + live_socket + .socket() + .disconnect() + .await + .expect("shutdown error"); + + assert_eq!( + live_channel.channel().status(), + ChannelStatus::WaitingForSocketToConnect + ); + + assert!(!handle.is_finished()); + + // reconnect + live_socket + .socket() + .connect(Duration::from_millis(1000)) + .await + .expect("shutdown error"); + + assert_eq!( + live_channel.channel().status(), + ChannelStatus::WaitingToJoin + ); + + live_channel.rejoin().await.expect("Could not rejoin"); + + // We are listening to events again + assert_eq!(live_channel.channel().status(), ChannelStatus::Joined); + // The merge diff event has not exited. + assert!(!handle.is_finished()); +} + +#[tokio::test] +async fn channels_drop_on_shutdown() { + let _ = env_logger::builder() + .parse_default_env() + .is_test(true) + .try_init(); + + let url = format!("http://{HOST}/hello"); + + let live_socket = LiveSocket::new(url.to_string(), "swiftui".into(), Default::default()) + .await + .expect("Failed to get liveview socket"); + + let live_channel = live_socket + .join_liveview_channel(None, None) + .await + .expect("Failed to join channel"); + + let chan_clone = live_channel.channel().clone(); + let handle = tokio::spawn(async move { + live_channel + .merge_diffs() + .await + .expect("Failed to merge diffs"); + }); + + live_socket + .socket() + .shutdown() + .await + .expect("shutdown error"); + + assert!(handle.is_finished()); + assert_eq!(chan_clone.status(), ChannelStatus::ShutDown); +} + #[tokio::test] async fn redirect() { let _ = env_logger::builder() diff --git a/crates/core/src/live_socket/tests/navigation.rs b/crates/core/src/live_socket/tests/navigation.rs index 25507445..d630bcd4 100644 --- a/crates/core/src/live_socket/tests/navigation.rs +++ b/crates/core/src/live_socket/tests/navigation.rs @@ -3,7 +3,6 @@ use std::sync::{Arc, Mutex}; use super::assert_doc_eq; use crate::live_socket::navigation::*; use crate::live_socket::LiveSocket; -use pretty_assertions::assert_eq; use reqwest::Url; use serde::{Deserialize, Serialize}; From 1dd96c58969cb579969a9c2fa8693adc55861e54 Mon Sep 17 00:00:00 2001 From: mobile-bungalow Date: Mon, 2 Dec 2024 18:33:54 -0800 Subject: [PATCH 04/21] event tags --- crates/core/src/live_socket/channel.rs | 37 +++++++++++++++++++++++-- crates/core/src/live_socket/mod.rs | 1 + crates/core/src/live_socket/protocol.rs | 0 crates/core/src/live_socket/socket.rs | 2 ++ 4 files changed, 37 insertions(+), 3 deletions(-) delete mode 100644 crates/core/src/live_socket/protocol.rs diff --git a/crates/core/src/live_socket/channel.rs b/crates/core/src/live_socket/channel.rs index faae00eb..984d002c 100644 --- a/crates/core/src/live_socket/channel.rs +++ b/crates/core/src/live_socket/channel.rs @@ -1,13 +1,17 @@ use futures::{future::FutureExt, pin_mut, select}; -use std::{sync::Arc, time::Duration}; +use std::{ + collections::HashSet, + sync::{Arc, Mutex}, + time::Duration, +}; -use super::{LiveSocketError, UploadConfig, UploadError}; +use super::{protocol::PhxEvent, LiveSocketError, UploadConfig, UploadError}; use crate::{ diff::fragment::{Root, RootDiff}, dom::{ ffi::{Document as FFiDocument, DocumentChangeHandler}, - AttributeName, AttributeValue, Document, Selector, + AttributeName, AttributeValue, Document, NodeRef, Selector, }, parser::parse, }; @@ -21,6 +25,7 @@ pub struct LiveChannel { pub join_payload: Payload, pub document: FFiDocument, pub timeout: Duration, + pub locks: Mutex>, } #[derive(uniffi::Object)] @@ -85,6 +90,14 @@ impl LiveChannel { debug!("Join payload render:\n{document}"); Ok(document) } + + fn unlock_node(&self, node: NodeRef) { + self.locks.lock().expect("lock poison").insert(node); + } + + fn lock_node(&self, node: NodeRef) { + self.locks.lock().expect("lock poison").insert(node); + } } #[cfg_attr(not(target_family = "wasm"), uniffi::export(async_runtime = "tokio"))] @@ -101,6 +114,24 @@ impl LiveChannel { self.document.set_event_handler(handler); } + pub async fn send_event( + &self, + event: PhxEvent, + payload: &str, + sender: &NodeRef, + ) -> Result { + todo!(); + } + + pub async fn send_event_json( + &self, + event: PhxEvent, + payload: JSON, + sender: &NodeRef, + ) -> Result { + todo!(); + } + pub fn get_phx_upload_id(&self, phx_target_name: &str) -> Result { // find the upload with target equal to phx_target_name // retrieve the security token diff --git a/crates/core/src/live_socket/mod.rs b/crates/core/src/live_socket/mod.rs index 25b66e22..c2451f05 100644 --- a/crates/core/src/live_socket/mod.rs +++ b/crates/core/src/live_socket/mod.rs @@ -1,6 +1,7 @@ mod channel; mod error; mod navigation; +mod protocol; mod socket; #[cfg(test)] diff --git a/crates/core/src/live_socket/protocol.rs b/crates/core/src/live_socket/protocol.rs deleted file mode 100644 index e69de29b..00000000 diff --git a/crates/core/src/live_socket/socket.rs b/crates/core/src/live_socket/socket.rs index 7af3a19e..73fe8c0c 100644 --- a/crates/core/src/live_socket/socket.rs +++ b/crates/core/src/live_socket/socket.rs @@ -424,6 +424,7 @@ impl LiveSocket { socket: self.socket(), document: document.into(), timeout: self.timeout(), + locks: Default::default(), }) } @@ -537,6 +538,7 @@ impl LiveSocket { socket: self.socket(), document: document.into(), timeout: self.timeout(), + locks: Default::default(), }) } From a0f628c1e9abc0ab1393082d37aab8b9c51eeddb Mon Sep 17 00:00:00 2001 From: mobile-bungalow Date: Tue, 3 Dec 2024 19:04:10 -0800 Subject: [PATCH 05/21] initial nop locking --- crates/core/src/diff/mod.rs | 2 +- crates/core/src/diff/patch.rs | 175 ++++++++++++++---- crates/core/src/dom/attribute.rs | 2 + crates/core/src/dom/mod.rs | 33 +++- crates/core/src/dom/node.rs | 12 +- crates/core/src/dom/phoenix_consts.rs | 23 +++ crates/core/src/live_socket/channel.rs | 9 +- crates/core/src/live_socket/protocol/event.rs | 87 +++++++++ crates/core/src/live_socket/protocol/mod.rs | 1 + crates/core/src/live_socket/tests/mod.rs | 2 + .../src/live_socket/tests/playback/mod.rs | 1 + .../src/live_socket/tests/playback/support.rs | 0 12 files changed, 293 insertions(+), 54 deletions(-) create mode 100644 crates/core/src/dom/phoenix_consts.rs create mode 100644 crates/core/src/live_socket/protocol/event.rs create mode 100644 crates/core/src/live_socket/protocol/mod.rs create mode 100644 crates/core/src/live_socket/tests/playback/mod.rs create mode 100644 crates/core/src/live_socket/tests/playback/support.rs diff --git a/crates/core/src/diff/mod.rs b/crates/core/src/diff/mod.rs index 8df966dd..4e987e1d 100644 --- a/crates/core/src/diff/mod.rs +++ b/crates/core/src/diff/mod.rs @@ -4,5 +4,5 @@ mod patch; mod traversal; pub use morph::{diff, Morph}; -pub use patch::{Patch, PatchResult}; +pub use patch::{BeforePatch, Patch, PatchResult}; pub use traversal::MoveTo; diff --git a/crates/core/src/diff/patch.rs b/crates/core/src/diff/patch.rs index e78c262c..806672d8 100644 --- a/crates/core/src/diff/patch.rs +++ b/crates/core/src/diff/patch.rs @@ -89,6 +89,19 @@ pub enum Patch { Move(MoveTo), } +/// Speculative action taken before a patch +#[derive(Debug)] +pub enum BeforePatch { + /// A new node would be added to `parent`. + WouldAdd { parent: NodeRef }, + /// The node will be removed from `parent` + WouldRemove { node: NodeRef, parent: NodeRef }, + /// The node will be modified + WouldChange { node: NodeRef }, + /// The node will be replaced + WouldReplace { node: NodeRef, parent: NodeRef }, +} + /// The result of applying a [Patch]. #[derive(Debug)] pub enum PatchResult { @@ -125,20 +138,34 @@ impl Patch { { match self { Self::InsertBefore { before, node: data } => { - let node = doc.insert_before(data.clone(), before); let parent = doc .document() - .parent(node) + .parent(before) .expect("inserted node should have parent"); - Some(PatchResult::Add { node, parent, data }) + + let speculative = BeforePatch::WouldAdd { parent }; + + if doc.document().can_complete_change(&speculative) { + let node = doc.insert_before(data.clone(), before); + Some(PatchResult::Add { node, parent, data }) + } else { + None + } } Self::InsertAfter { after, node: data } => { - let node = doc.insert_after(data.clone(), after); let parent = doc .document() - .parent(node) + .parent(after) .expect("inserted node should have parent"); - Some(PatchResult::Add { node, parent, data }) + + let speculative = BeforePatch::WouldAdd { parent }; + + if doc.document().can_complete_change(&speculative) { + let node = doc.insert_after(data.clone(), after); + Some(PatchResult::Add { node, parent, data }) + } else { + None + } } Self::Create { node } => { let node = doc.push_node(node); @@ -183,11 +210,18 @@ impl Patch { } Self::PrependBefore { before } => { let node = stack.pop().unwrap(); + let d = doc.document_mut(); - d.insert_before(node, before); let parent = d.parent(before).expect("inserted node should have parent"); - let data = d.get(node).clone(); - Some(PatchResult::Add { node, parent, data }) + let speculative = BeforePatch::WouldAdd { parent }; + + if d.can_complete_change(&speculative) { + d.insert_before(node, before); + let data = d.get(node).clone(); + Some(PatchResult::Add { node, parent, data }) + } else { + None + } } Self::Append { node: data } => { let node = doc.append(data.clone()); @@ -198,62 +232,121 @@ impl Patch { }) } Self::AppendTo { parent, node: data } => { - let node = doc.append_child(parent, data.clone()); - Some(PatchResult::Add { node, parent, data }) + let speculative = BeforePatch::WouldAdd { parent }; + + if doc.document().can_complete_change(&speculative) { + let node = doc.append_child(parent, data.clone()); + Some(PatchResult::Add { node, parent, data }) + } else { + None + } } Self::AppendAfter { after } => { - let node = stack.pop().unwrap(); let d = doc.document_mut(); - d.insert_after(node, after); let parent = d.parent(after).expect("inserted node should have parent"); - let data = d.get(node).clone(); - Some(PatchResult::Add { node, parent, data }) + + let speculative = BeforePatch::WouldAdd { parent }; + + if d.can_complete_change(&speculative) { + let node = stack.pop().unwrap(); + d.insert_after(node, after); + let data = d.get(node).clone(); + Some(PatchResult::Add { node, parent, data }) + } else { + None + } } Self::Remove { node } => { let data = doc.document().get(node).clone(); let parent = doc.document_mut().parent(node); - doc.remove(node); - parent.map(|parent| PatchResult::Remove { node, parent, data }) + + let can_remove = if let Some(parent) = parent { + let speculative = BeforePatch::WouldRemove { node, parent }; + doc.document().can_complete_change(&speculative) + } else { + false + }; + + if can_remove { + doc.remove(node); + parent.map(|parent| PatchResult::Remove { node, parent, data }) + } else { + None + } } Self::Replace { node, replacement } => { let data = doc.document().get(node).clone(); let parent = doc.document_mut().parent(node)?; - doc.replace(node, replacement); - Some(PatchResult::Replace { node, parent, data }) + + let speculative = BeforePatch::WouldReplace { node, parent }; + + if doc.document().can_complete_change(&speculative) { + doc.replace(node, replacement); + Some(PatchResult::Replace { node, parent, data }) + } else { + None + } } Self::AddAttribute { name, value } => { doc.set_attribute(name, value); let node = doc.insertion_point(); - let data = doc.document().get(node).clone(); - Some(PatchResult::Change { node, data }) + + let speculative = BeforePatch::WouldChange { node }; + + if doc.document().can_complete_change(&speculative) { + let data = doc.document().get(node).clone(); + Some(PatchResult::Change { node, data }) + } else { + None + } } Self::AddAttributeTo { node, name, value } => { - let data = doc.document().get(node).clone(); - let mut guard = doc.insert_guard(); - guard.set_insertion_point(node); - guard.set_attribute(name, value); - Some(PatchResult::Change { node, data }) + let speculative = BeforePatch::WouldChange { node }; + if doc.document().can_complete_change(&speculative) { + let data = doc.document().get(node).clone(); + let mut guard = doc.insert_guard(); + guard.set_insertion_point(node); + guard.set_attribute(name, value); + Some(PatchResult::Change { node, data }) + } else { + None + } } Self::UpdateAttribute { node, name, value } => { - let data = doc.document().get(node).clone(); - let mut guard = doc.insert_guard(); - guard.set_insertion_point(node); - guard.set_attribute(name, value); - Some(PatchResult::Change { node, data }) + let speculative = BeforePatch::WouldChange { node }; + if doc.document().can_complete_change(&speculative) { + let data = doc.document().get(node).clone(); + let mut guard = doc.insert_guard(); + guard.set_insertion_point(node); + guard.set_attribute(name, value); + Some(PatchResult::Change { node, data }) + } else { + None + } } Self::RemoveAttributeByName { node, name } => { - let data = doc.document().get(node).clone(); - let mut guard = doc.insert_guard(); - guard.set_insertion_point(node); - guard.remove_attribute(name); - Some(PatchResult::Change { node, data }) + let speculative = BeforePatch::WouldChange { node }; + if doc.document().can_complete_change(&speculative) { + let data = doc.document().get(node).clone(); + let mut guard = doc.insert_guard(); + guard.set_insertion_point(node); + guard.remove_attribute(name); + Some(PatchResult::Change { node, data }) + } else { + None + } } Self::SetAttributes { node, attributes } => { - let data = doc.document().get(node).clone(); - let mut guard = doc.insert_guard(); - guard.set_insertion_point(node); - guard.replace_attributes(attributes); - Some(PatchResult::Change { node, data }) + let speculative = BeforePatch::WouldChange { node }; + if doc.document().can_complete_change(&speculative) { + let data = doc.document().get(node).clone(); + let mut guard = doc.insert_guard(); + guard.set_insertion_point(node); + guard.replace_attributes(attributes); + Some(PatchResult::Change { node, data }) + } else { + None + } } Self::Move(MoveTo::Node(node)) => { doc.set_insertion_point(node); diff --git a/crates/core/src/dom/attribute.rs b/crates/core/src/dom/attribute.rs index 4b73ad27..36bf3238 100644 --- a/crates/core/src/dom/attribute.rs +++ b/crates/core/src/dom/attribute.rs @@ -11,6 +11,7 @@ pub struct AttributeName { pub namespace: Option, pub name: String, } + impl fmt::Display for AttributeName { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { if let Some(ref ns) = self.namespace { @@ -62,6 +63,7 @@ pub struct Attribute { pub name: AttributeName, pub value: Option, } + impl Attribute { /// Creates a new attribute with the given name and value /// diff --git a/crates/core/src/dom/mod.rs b/crates/core/src/dom/mod.rs index 87882c46..ced66bdf 100644 --- a/crates/core/src/dom/mod.rs +++ b/crates/core/src/dom/mod.rs @@ -29,7 +29,7 @@ pub use self::{ use crate::{ diff::{ fragment::{FragmentMerge, RenderError, Root, RootDiff}, - PatchResult, + BeforePatch, PatchResult, }, parser, }; @@ -70,6 +70,7 @@ pub struct Document { root: NodeRef, /// The fragment template. pub fragment_template: Option, + /// Instruments all patch events which are applied to the document. pub event_callback: Option>, /// A map from node reference to node data nodes: PrimaryMap, @@ -530,6 +531,30 @@ impl Document { Ok(document) } + fn node_parents_are_locked(&self, node: NodeRef) -> bool { + false + } + + fn node_descendents_are_locked(&self, node: NodeRef) -> bool { + false + } + + /// If a patch result would touch a part of the locked tree, return true. + /// These changes are not kept in the DOM but instead in the root fragment. + pub fn can_complete_change(&self, patch: &BeforePatch) -> bool { + match patch { + BeforePatch::WouldAdd { parent } => !self.node_parents_are_locked(*parent), + BeforePatch::WouldChange { node } => !self.node_parents_are_locked(*node), + BeforePatch::WouldRemove { node, parent } => { + !self.node_parents_are_locked(*parent) && !self.node_descendents_are_locked(*node) + } + + BeforePatch::WouldReplace { node, parent } => { + !self.node_parents_are_locked(*parent) && !self.node_descendents_are_locked(*node) + } + } + } + pub fn merge_fragment_json( &mut self, value: serde_json::Value, @@ -541,18 +566,17 @@ impl Document { } else { fragment.try_into()? }; + self.fragment_template = Some(root.clone()); let rendered_root: String = root.clone().try_into()?; let new_doc = Self::parse(rendered_root)?; let patches = crate::diff::diff(self, &new_doc); - if patches.is_empty() { - return Ok(vec![]); - } let mut stack = vec![]; let mut editor = self.edit(); + let results = patches .into_iter() .filter_map(|patch| patch.apply(&mut editor, &mut stack)) @@ -917,6 +941,7 @@ pub struct Editor<'a> { doc: &'a mut Document, pos: NodeRef, } + impl<'a> Editor<'a> { pub fn new(doc: &'a mut Document) -> Self { let pos = doc.root(); diff --git a/crates/core/src/dom/node.rs b/crates/core/src/dom/node.rs index 8475fc80..c9a2d9ec 100644 --- a/crates/core/src/dom/node.rs +++ b/crates/core/src/dom/node.rs @@ -117,12 +117,16 @@ impl Node { pub fn attributes(&self) -> Vec { self.data.attributes() } + pub fn get_attribute(&self, name: AttributeName) -> Option { - self.attributes() - .iter() - .find(|attr| attr.name == name) - .cloned() + let attrs = match &self.data { + NodeData::NodeElement { element } => &element.attributes, + _ => return None, + }; + + attrs.iter().find(|attr| attr.name == name).cloned() } + pub fn display(&self) -> String { format!("{self}") } diff --git a/crates/core/src/dom/phoenix_consts.rs b/crates/core/src/dom/phoenix_consts.rs new file mode 100644 index 00000000..582e8848 --- /dev/null +++ b/crates/core/src/dom/phoenix_consts.rs @@ -0,0 +1,23 @@ +use super::{Attribute, AttributeName}; + +#[macro_export] +macro_rules! const_attr { + ($name:ident, $attr_name:expr, $value:expr) => { + const $name: $crate::Attribute = $crate::Attribute { + name: $crate::AttributeName { + namespace: None, + name: $attr_name.to_string(), + }, + value: Some($value.to_string()), + }; + }; + ($name:ident, $attr_name:expr) => { + const $name: $crate::Attribute = $crate::Attribute { + name: $crate::AttributeName { + namespace: None, + name: $attr_name.to_string(), + }, + value: None, + }; + }; +} diff --git a/crates/core/src/live_socket/channel.rs b/crates/core/src/live_socket/channel.rs index 984d002c..abdf7e58 100644 --- a/crates/core/src/live_socket/channel.rs +++ b/crates/core/src/live_socket/channel.rs @@ -6,7 +6,7 @@ use std::{ time::Duration, }; -use super::{protocol::PhxEvent, LiveSocketError, UploadConfig, UploadError}; +use super::{protocol::event::PhxEvent, LiveSocketError, UploadConfig, UploadError}; use crate::{ diff::fragment::{Root, RootDiff}, dom::{ @@ -91,11 +91,11 @@ impl LiveChannel { Ok(document) } - fn unlock_node(&self, node: NodeRef) { + pub fn unlock_node(&self, node: NodeRef) { self.locks.lock().expect("lock poison").insert(node); } - fn lock_node(&self, node: NodeRef) { + pub fn lock_node(&self, node: NodeRef) { self.locks.lock().expect("lock poison").insert(node); } } @@ -196,10 +196,11 @@ impl LiveChannel { error!("Diff was not json!"); continue; }; + debug!("PAYLOAD: {json:?}"); // This function merges and uses the event handler set in `set_event_handler` // which will call back into the Swift/Kotlin. - document.merge_fragment_json(&json.to_string())?; + document.merge_fragment_json_unserialized(json)?; } } }; diff --git a/crates/core/src/live_socket/protocol/event.rs b/crates/core/src/live_socket/protocol/event.rs new file mode 100644 index 00000000..3b5f1205 --- /dev/null +++ b/crates/core/src/live_socket/protocol/event.rs @@ -0,0 +1,87 @@ +use std::borrow::Cow; + +#[derive(uniffi::Enum, Clone)] +pub enum PhxEvent { + Other(String), + PhxValue(String), + PhxClick, + PhxClickAway, + PhxChange, + PhxSubmit, + PhxFeedbackFor, + PhxFeedbackGroup, + PhxDisableWith, + PhxTriggerAction, + PhxAutoRecover, + PhxBlur, + PhxFocus, + PhxWindowBlur, + PhxWindowFocus, + PhxKeydown, + PhxKeyup, + PhxWindowKeydown, + PhxWindowKeyup, + PhxKey, + PhxViewportTop, + PhxViewportBottom, + PhxMounted, + PhxUpdate, + PhxRemove, + PhxHook, + PhxConnected, + PhxDisconnected, + PhxDebounce, + PhxThrottle, + PhxTrackStatic, +} + +impl PhxEvent { + fn str_name<'a>(&'a self) -> Cow<'a, str> { + match self { + PhxEvent::Other(o) => Cow::Borrowed(o.as_str()), + PhxEvent::PhxValue(var_name) => ["phx-value-", var_name.as_str()].concat().into(), + PhxEvent::PhxClick => "phx-click".into(), + PhxEvent::PhxClickAway => "phx-click-away".into(), + PhxEvent::PhxChange => "phx-change".into(), + PhxEvent::PhxSubmit => "phx-submit".into(), + PhxEvent::PhxFeedbackFor => "phx-feedback-for".into(), + PhxEvent::PhxFeedbackGroup => "phx-feedback-group".into(), + PhxEvent::PhxDisableWith => "phx-disable-with".into(), + PhxEvent::PhxTriggerAction => "phx-trigger-action".into(), + PhxEvent::PhxAutoRecover => "phx-auto-recover".into(), + PhxEvent::PhxBlur => "phx-blur".into(), + PhxEvent::PhxFocus => "phx-focus".into(), + PhxEvent::PhxWindowBlur => "phx-window-blur".into(), + PhxEvent::PhxWindowFocus => "phx-window-focus".into(), + PhxEvent::PhxKeydown => "phx-keydown".into(), + PhxEvent::PhxKeyup => "phx-keyup".into(), + PhxEvent::PhxWindowKeydown => "phx-window-keydown".into(), + PhxEvent::PhxWindowKeyup => "phx-window-keyup".into(), + PhxEvent::PhxKey => "phx-key".into(), + PhxEvent::PhxViewportTop => "phx-viewport-top".into(), + PhxEvent::PhxViewportBottom => "phx-viewport-bottom".into(), + PhxEvent::PhxMounted => "phx-mounted".into(), + PhxEvent::PhxUpdate => "phx-update".into(), + PhxEvent::PhxRemove => "phx-remove".into(), + PhxEvent::PhxHook => "phx-hook".into(), + PhxEvent::PhxConnected => "phx-connected".into(), + PhxEvent::PhxDisconnected => "phx-disconnected".into(), + PhxEvent::PhxDebounce => "phx-debounce".into(), + PhxEvent::PhxThrottle => "phx-throttle".into(), + PhxEvent::PhxTrackStatic => "phx-track-static".into(), + } + } + + fn loading_attr(&self) -> Option<&str> { + match self { + PhxEvent::PhxClick => Some("phx-click-loading"), + PhxEvent::PhxChange => Some("phx-change-loading"), + PhxEvent::PhxSubmit => Some("phx-submit-loading"), + PhxEvent::PhxFocus => Some("phx-focus-loading"), + PhxEvent::PhxBlur => Some("phx-blur-loading"), + PhxEvent::PhxWindowKeydown => Some("phx-keydown-loading"), + PhxEvent::PhxWindowKeyup => Some("phx-keyup-loading"), + _ => None, + } + } +} diff --git a/crates/core/src/live_socket/protocol/mod.rs b/crates/core/src/live_socket/protocol/mod.rs new file mode 100644 index 00000000..53f11265 --- /dev/null +++ b/crates/core/src/live_socket/protocol/mod.rs @@ -0,0 +1 @@ +pub mod event; diff --git a/crates/core/src/live_socket/tests/mod.rs b/crates/core/src/live_socket/tests/mod.rs index 42a881f3..7ee385ae 100644 --- a/crates/core/src/live_socket/tests/mod.rs +++ b/crates/core/src/live_socket/tests/mod.rs @@ -3,6 +3,7 @@ use std::time::Duration; use super::*; mod error; mod navigation; +mod playback; mod streaming; mod upload; @@ -128,6 +129,7 @@ async fn channels_keep_listening_for_diffs_on_reconnect() { assert!(!handle.is_finished()); } +// Validate that shutdown has side effects. #[tokio::test] async fn channels_drop_on_shutdown() { let _ = env_logger::builder() diff --git a/crates/core/src/live_socket/tests/playback/mod.rs b/crates/core/src/live_socket/tests/playback/mod.rs new file mode 100644 index 00000000..f1a275f2 --- /dev/null +++ b/crates/core/src/live_socket/tests/playback/mod.rs @@ -0,0 +1 @@ +mod support; diff --git a/crates/core/src/live_socket/tests/playback/support.rs b/crates/core/src/live_socket/tests/playback/support.rs new file mode 100644 index 00000000..e69de29b From 0941c3a4942ca77024fc5d74e009f62c312f78ca Mon Sep 17 00:00:00 2001 From: mobile-bungalow Date: Tue, 3 Dec 2024 19:04:34 -0800 Subject: [PATCH 06/21] remove cruft --- crates/core/src/live_socket/tests/playback/mod.rs | 1 - crates/core/src/live_socket/tests/playback/support.rs | 0 2 files changed, 1 deletion(-) delete mode 100644 crates/core/src/live_socket/tests/playback/mod.rs delete mode 100644 crates/core/src/live_socket/tests/playback/support.rs diff --git a/crates/core/src/live_socket/tests/playback/mod.rs b/crates/core/src/live_socket/tests/playback/mod.rs deleted file mode 100644 index f1a275f2..00000000 --- a/crates/core/src/live_socket/tests/playback/mod.rs +++ /dev/null @@ -1 +0,0 @@ -mod support; diff --git a/crates/core/src/live_socket/tests/playback/support.rs b/crates/core/src/live_socket/tests/playback/support.rs deleted file mode 100644 index e69de29b..00000000 From 1e391b262552deea24da903f00799924550c73ad Mon Sep 17 00:00:00 2001 From: mobile-bungalow Date: Tue, 3 Dec 2024 19:04:41 -0800 Subject: [PATCH 07/21] remove cruft --- crates/core/src/live_socket/tests/mod.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/core/src/live_socket/tests/mod.rs b/crates/core/src/live_socket/tests/mod.rs index 7ee385ae..3831f3c6 100644 --- a/crates/core/src/live_socket/tests/mod.rs +++ b/crates/core/src/live_socket/tests/mod.rs @@ -3,7 +3,6 @@ use std::time::Duration; use super::*; mod error; mod navigation; -mod playback; mod streaming; mod upload; From ec279caabf34262c19c87ac9e972f69e2c4a8279 Mon Sep 17 00:00:00 2001 From: mobile-bungalow Date: Tue, 3 Dec 2024 19:58:25 -0800 Subject: [PATCH 08/21] simple locking --- crates/core/src/diff/patch.rs | 8 ++++---- crates/core/src/dom/mod.rs | 24 ++++++++++-------------- crates/core/src/dom/node.rs | 13 +++++++++++++ 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/crates/core/src/diff/patch.rs b/crates/core/src/diff/patch.rs index 806672d8..23fe6fa9 100644 --- a/crates/core/src/diff/patch.rs +++ b/crates/core/src/diff/patch.rs @@ -95,11 +95,11 @@ pub enum BeforePatch { /// A new node would be added to `parent`. WouldAdd { parent: NodeRef }, /// The node will be removed from `parent` - WouldRemove { node: NodeRef, parent: NodeRef }, + WouldRemove { node: NodeRef }, /// The node will be modified WouldChange { node: NodeRef }, /// The node will be replaced - WouldReplace { node: NodeRef, parent: NodeRef }, + WouldReplace { node: NodeRef }, } /// The result of applying a [Patch]. @@ -261,7 +261,7 @@ impl Patch { let parent = doc.document_mut().parent(node); let can_remove = if let Some(parent) = parent { - let speculative = BeforePatch::WouldRemove { node, parent }; + let speculative = BeforePatch::WouldRemove { node }; doc.document().can_complete_change(&speculative) } else { false @@ -278,7 +278,7 @@ impl Patch { let data = doc.document().get(node).clone(); let parent = doc.document_mut().parent(node)?; - let speculative = BeforePatch::WouldReplace { node, parent }; + let speculative = BeforePatch::WouldReplace { node }; if doc.document().can_complete_change(&speculative) { doc.replace(node, replacement); diff --git a/crates/core/src/dom/mod.rs b/crates/core/src/dom/mod.rs index ced66bdf..332e8b26 100644 --- a/crates/core/src/dom/mod.rs +++ b/crates/core/src/dom/mod.rs @@ -531,26 +531,22 @@ impl Document { Ok(document) } - fn node_parents_are_locked(&self, node: NodeRef) -> bool { - false - } - - fn node_descendents_are_locked(&self, node: NodeRef) -> bool { - false - } - /// If a patch result would touch a part of the locked tree, return true. /// These changes are not kept in the DOM but instead in the root fragment. pub fn can_complete_change(&self, patch: &BeforePatch) -> bool { match patch { - BeforePatch::WouldAdd { parent } => !self.node_parents_are_locked(*parent), - BeforePatch::WouldChange { node } => !self.node_parents_are_locked(*node), - BeforePatch::WouldRemove { node, parent } => { - !self.node_parents_are_locked(*parent) && !self.node_descendents_are_locked(*node) + BeforePatch::WouldAdd { parent } => { + !self.get(*parent).has_attribute("phx-data-ref-lock", None) + } + BeforePatch::WouldChange { node } => { + !self.get(*node).has_attribute("phx-data-ref-lock", None) + } + BeforePatch::WouldRemove { node } => { + !self.get(*node).has_attribute("phx-data-ref-lock", None) } - BeforePatch::WouldReplace { node, parent } => { - !self.node_parents_are_locked(*parent) && !self.node_descendents_are_locked(*node) + BeforePatch::WouldReplace { node } => { + !self.get(*node).has_attribute("phx-data-ref-lock", None) } } } diff --git a/crates/core/src/dom/node.rs b/crates/core/src/dom/node.rs index c9a2d9ec..ec7cf169 100644 --- a/crates/core/src/dom/node.rs +++ b/crates/core/src/dom/node.rs @@ -111,9 +111,11 @@ impl Node { pub fn id(&self) -> NodeRef { self.id } + pub fn data(&self) -> NodeData { self.data.clone() } + pub fn attributes(&self) -> Vec { self.data.attributes() } @@ -132,6 +134,17 @@ impl Node { } } impl NodeData { + pub fn has_attribute(&self, name: &str, namespace: Option<&str>) -> bool { + let attrs = match &self { + NodeData::NodeElement { element } => &element.attributes, + _ => return false, + }; + + attrs + .iter() + .any(|attr| attr.name.name == name && attr.name.namespace.as_deref() == namespace) + } + /// Returns a slice of Attributes for this node, if applicable pub fn attributes(&self) -> Vec { match self { From fe5714b9cb3163631867700c863ed09e5f12564c Mon Sep 17 00:00:00 2001 From: mobile-bungalow Date: Wed, 4 Dec 2024 00:54:21 -0800 Subject: [PATCH 09/21] loosely couple phx event logic in case we need to dynamic dispatch in the future --- crates/core/src/diff/patch.rs | 7 ++-- crates/core/src/dom/ffi.rs | 7 ++-- crates/core/src/dom/mod.rs | 64 +++++++++++++++++++++++------------ 3 files changed, 49 insertions(+), 29 deletions(-) diff --git a/crates/core/src/diff/patch.rs b/crates/core/src/diff/patch.rs index 23fe6fa9..24699ed2 100644 --- a/crates/core/src/diff/patch.rs +++ b/crates/core/src/diff/patch.rs @@ -138,11 +138,8 @@ impl Patch { { match self { Self::InsertBefore { before, node: data } => { - let parent = doc - .document() - .parent(before) - .expect("inserted node should have parent"); - + let d = doc.document(); + let parent = d.parent(before).expect("inserted node should have parent"); let speculative = BeforePatch::WouldAdd { parent }; if doc.document().can_complete_change(&speculative) { diff --git a/crates/core/src/dom/ffi.rs b/crates/core/src/dom/ffi.rs index c6741e61..d9235681 100644 --- a/crates/core/src/dom/ffi.rs +++ b/crates/core/src/dom/ffi.rs @@ -61,7 +61,10 @@ impl Document { } pub fn set_event_handler(&self, handler: Box) { - self.inner.lock().expect("lock poisoned!").event_callback = Some(Arc::from(handler)); + self.inner + .lock() + .expect("lock poisoned!") + .user_event_callback = Some(Arc::from(handler)); } pub fn merge_fragment_json_unserialized(&self, json: JSON) -> Result<(), RenderError> { @@ -76,7 +79,7 @@ impl Document { .inner .lock() .expect("lock poisoned") - .event_callback + .user_event_callback .clone() else { return Ok(()); diff --git a/crates/core/src/dom/mod.rs b/crates/core/src/dom/mod.rs index 332e8b26..39723174 100644 --- a/crates/core/src/dom/mod.rs +++ b/crates/core/src/dom/mod.rs @@ -71,7 +71,11 @@ pub struct Document { /// The fragment template. pub fragment_template: Option, /// Instruments all patch events which are applied to the document. - pub event_callback: Option>, + pub user_event_callback: Option>, + /// liveview specific logic for reacting to document changes. + /// intentionally loosely coupled from dom logic. this should be converted + /// to a dynamic trait if user augmented dynamic dispatch is ever needed. + phx_event_callbacks: Option, /// A map from node reference to node data nodes: PrimaryMap, /// A map from a node to its parent node, if it currently has one @@ -134,11 +138,20 @@ impl Document { children: SecondaryMap::new(), ids: Default::default(), fragment_template: None, - event_callback: None, + user_event_callback: None, + phx_event_callbacks: Some(PhxDocumentChangeHooks), upload_ct: 0, } } + pub fn can_complete_change(&self, patch: &BeforePatch) -> bool { + if let Some(hooks) = self.phx_event_callbacks.as_ref() { + hooks.can_complete_change(self, patch) + } else { + true + } + } + /// Parses a `Document` from a string pub fn parse>(input: S) -> Result { parser::parse(input.as_ref()) @@ -531,26 +544,6 @@ impl Document { Ok(document) } - /// If a patch result would touch a part of the locked tree, return true. - /// These changes are not kept in the DOM but instead in the root fragment. - pub fn can_complete_change(&self, patch: &BeforePatch) -> bool { - match patch { - BeforePatch::WouldAdd { parent } => { - !self.get(*parent).has_attribute("phx-data-ref-lock", None) - } - BeforePatch::WouldChange { node } => { - !self.get(*node).has_attribute("phx-data-ref-lock", None) - } - BeforePatch::WouldRemove { node } => { - !self.get(*node).has_attribute("phx-data-ref-lock", None) - } - - BeforePatch::WouldReplace { node } => { - !self.get(*node).has_attribute("phx-data-ref-lock", None) - } - } - } - pub fn merge_fragment_json( &mut self, value: serde_json::Value, @@ -660,6 +653,33 @@ pub trait DocumentChangeHandler: Send + Sync { ); } +/// Applications specific dom morphing hooks. +/// These functions implement application specific +#[derive(Debug, Clone)] +pub struct PhxDocumentChangeHooks; + +impl PhxDocumentChangeHooks { + /// If a patch result would touch a part of the locked tree, return true. + /// These changes are not kept in the DOM but instead in the root fragment. + pub fn can_complete_change(&self, doc: &Document, patch: &BeforePatch) -> bool { + match patch { + BeforePatch::WouldAdd { parent } => { + !doc.get(*parent).has_attribute("phx-data-ref-lock", None) + } + BeforePatch::WouldChange { node } => { + !doc.get(*node).has_attribute("phx-data-ref-lock", None) + } + BeforePatch::WouldRemove { node } => { + !doc.get(*node).has_attribute("phx-data-ref-lock", None) + } + + BeforePatch::WouldReplace { node } => { + !doc.get(*node).has_attribute("phx-data-ref-lock", None) + } + } + } +} + /// This trait is used to provide functionality common to construction/mutating documents pub trait DocumentBuilder { fn document(&self) -> &Document; From 372dec196b9767c4f07cf889d8929471197b6571 Mon Sep 17 00:00:00 2001 From: mobile-bungalow Date: Thu, 5 Dec 2024 13:42:02 -0800 Subject: [PATCH 10/21] even sending pretest --- crates/core/src/diff/mod.rs | 2 +- crates/core/src/diff/patch.rs | 14 +-- crates/core/src/dom/mod.rs | 109 +++++++++++++----- crates/core/src/live_socket/channel.rs | 78 ++++++++----- crates/core/src/live_socket/mod.rs | 1 + crates/core/src/live_socket/protocol/event.rs | 16 ++- crates/core/src/live_socket/protocol/mod.rs | 38 ++++++ crates/core/src/live_socket/socket.rs | 8 +- 8 files changed, 191 insertions(+), 75 deletions(-) diff --git a/crates/core/src/diff/mod.rs b/crates/core/src/diff/mod.rs index 4e987e1d..8df966dd 100644 --- a/crates/core/src/diff/mod.rs +++ b/crates/core/src/diff/mod.rs @@ -4,5 +4,5 @@ mod patch; mod traversal; pub use morph::{diff, Morph}; -pub use patch::{BeforePatch, Patch, PatchResult}; +pub use patch::{Patch, PatchResult}; pub use traversal::MoveTo; diff --git a/crates/core/src/diff/patch.rs b/crates/core/src/diff/patch.rs index 24699ed2..4dec3710 100644 --- a/crates/core/src/diff/patch.rs +++ b/crates/core/src/diff/patch.rs @@ -1,5 +1,6 @@ use super::traversal::MoveTo; use crate::dom::*; +use crate::live_socket::dom_locking::*; #[derive(Debug, PartialEq, Clone)] pub enum Patch { @@ -89,19 +90,6 @@ pub enum Patch { Move(MoveTo), } -/// Speculative action taken before a patch -#[derive(Debug)] -pub enum BeforePatch { - /// A new node would be added to `parent`. - WouldAdd { parent: NodeRef }, - /// The node will be removed from `parent` - WouldRemove { node: NodeRef }, - /// The node will be modified - WouldChange { node: NodeRef }, - /// The node will be replaced - WouldReplace { node: NodeRef }, -} - /// The result of applying a [Patch]. #[derive(Debug)] pub enum PatchResult { diff --git a/crates/core/src/dom/mod.rs b/crates/core/src/dom/mod.rs index 39723174..c88c460a 100644 --- a/crates/core/src/dom/mod.rs +++ b/crates/core/src/dom/mod.rs @@ -4,6 +4,8 @@ mod node; mod printer; mod select; +use crate::live_socket::dom_locking::*; + use std::{ collections::{BTreeMap, VecDeque}, fmt, mem, @@ -29,7 +31,7 @@ pub use self::{ use crate::{ diff::{ fragment::{FragmentMerge, RenderError, Root, RootDiff}, - BeforePatch, PatchResult, + PatchResult, }, parser, }; @@ -139,11 +141,13 @@ impl Document { ids: Default::default(), fragment_template: None, user_event_callback: None, - phx_event_callbacks: Some(PhxDocumentChangeHooks), + phx_event_callbacks: None, upload_ct: 0, } } + /// Called before each patch is applied to the document, if this returns + /// false, then something in the document tree is locked. pub fn can_complete_change(&self, patch: &BeforePatch) -> bool { if let Some(hooks) = self.phx_event_callbacks.as_ref() { hooks.can_complete_change(self, patch) @@ -152,6 +156,18 @@ impl Document { } } + /// enable the change hook logic from phoenix live view. + /// this will prevent certain modifications from occurring to the DOM + /// under circumstances liek awaiting a server ack. + pub fn set_document_change_hooks(&mut self) { + self.phx_event_callbacks = Some(Default::default()); + } + + /// Disable phx specific locking logic. + pub fn unset_document_change_hooks(&mut self) { + self.phx_event_callbacks = None; + } + /// Parses a `Document` from a string pub fn parse>(input: S) -> Result { parser::parse(input.as_ref()) @@ -505,6 +521,68 @@ impl Document { } } + pub fn extend_class_list(&mut self, node: NodeRef, classes: &[&str]) { + let Some(NodeData::NodeElement { element }) = self.nodes.get_mut(node) else { + return; + }; + + let class_attr = element + .attributes + .iter_mut() + .find(|attr| attr.name.name == "class"); + + let class_attr = if let Some(attr) = class_attr { + attr + } else { + let attr = Attribute::new("class", None); + element.attributes.push(attr); + element.attributes.last_mut().unwrap() + }; + + let new_classes = classes.join(" "); + match &mut class_attr.value { + Some(existing) => { + existing.push_str(" "); + existing.push_str(&new_classes); + } + None => { + class_attr.value = Some(new_classes); + } + } + } + + pub fn remove_classes_by

(&mut self, node: NodeRef, mut predicate: P) + where + P: FnMut(&str) -> bool, + { + let Some(NodeData::NodeElement { element }) = self.nodes.get_mut(node) else { + return; + }; + + if let Some(class_attr) = element + .attributes + .iter_mut() + .find(|attr| attr.name.name == "class") + { + if let Some(value) = &mut class_attr.value { + *value = value + .split_whitespace() + .filter(|&class| !predicate(class)) + .collect::>() + .join(" "); + } + } + } + + pub fn add_attribute(&mut self, node: NodeRef, attribute: Attribute) { + if let NodeData::NodeElement { + element: ref mut elem, + } = &mut self.nodes[node] + { + elem.attributes.push(attribute) + } + } + /// Removes all attributes from `node` for which `predicate` returns false. pub fn remove_attributes_by

(&mut self, node: NodeRef, predicate: P) where @@ -653,33 +731,6 @@ pub trait DocumentChangeHandler: Send + Sync { ); } -/// Applications specific dom morphing hooks. -/// These functions implement application specific -#[derive(Debug, Clone)] -pub struct PhxDocumentChangeHooks; - -impl PhxDocumentChangeHooks { - /// If a patch result would touch a part of the locked tree, return true. - /// These changes are not kept in the DOM but instead in the root fragment. - pub fn can_complete_change(&self, doc: &Document, patch: &BeforePatch) -> bool { - match patch { - BeforePatch::WouldAdd { parent } => { - !doc.get(*parent).has_attribute("phx-data-ref-lock", None) - } - BeforePatch::WouldChange { node } => { - !doc.get(*node).has_attribute("phx-data-ref-lock", None) - } - BeforePatch::WouldRemove { node } => { - !doc.get(*node).has_attribute("phx-data-ref-lock", None) - } - - BeforePatch::WouldReplace { node } => { - !doc.get(*node).has_attribute("phx-data-ref-lock", None) - } - } - } -} - /// This trait is used to provide functionality common to construction/mutating documents pub trait DocumentBuilder { fn document(&self) -> &Document; diff --git a/crates/core/src/live_socket/channel.rs b/crates/core/src/live_socket/channel.rs index abdf7e58..113dafee 100644 --- a/crates/core/src/live_socket/channel.rs +++ b/crates/core/src/live_socket/channel.rs @@ -2,16 +2,20 @@ use futures::{future::FutureExt, pin_mut, select}; use std::{ collections::HashSet, - sync::{Arc, Mutex}, + sync::{atomic::AtomicU64, Arc, Mutex}, time::Duration, }; -use super::{protocol::event::PhxEvent, LiveSocketError, UploadConfig, UploadError}; +use super::{ + dom_locking::{self, PHX_REF_LOCK, PHX_REF_SRC}, + protocol::event::PhxEvent, + LiveSocketError, UploadConfig, UploadError, +}; use crate::{ diff::fragment::{Root, RootDiff}, dom::{ ffi::{Document as FFiDocument, DocumentChangeHandler}, - AttributeName, AttributeValue, Document, NodeRef, Selector, + Attribute, AttributeName, AttributeValue, Document, NodeRef, Selector, }, parser::parse, }; @@ -20,12 +24,12 @@ use phoenix_channels_client::{Channel, Event, Number, Payload, Socket, Topic, JS #[derive(uniffi::Object)] pub struct LiveChannel { + pub current_lock_id: Mutex, // Atomics not supported in wasm pub channel: Arc, pub socket: Arc, pub join_payload: Payload, pub document: FFiDocument, pub timeout: Duration, - pub locks: Mutex>, } #[derive(uniffi::Object)] @@ -91,12 +95,52 @@ impl LiveChannel { Ok(document) } - pub fn unlock_node(&self, node: NodeRef) { - self.locks.lock().expect("lock poison").insert(node); + pub fn next_id(&self) -> u64 { + let mut id = self.current_lock_id.lock().expect("lock_poison"); + *id += 1; + *id } - pub fn lock_node(&self, node: NodeRef) { - self.locks.lock().expect("lock poison").insert(node); + pub fn unlock_node(&self, node: NodeRef, loading_class: Option<&str>) { + self.document + .inner() + .lock() + .expect("lock poison") + .remove_attributes_by(node, |attr| attr.name.name == dom_locking::PHX_REF_LOCK); + + if let Some(loading_class) = loading_class { + self.document + .inner() + .lock() + .expect("lock poison") + .remove_classes_by(node, |class| class == loading_class); + } + } + + pub fn lock_node(&self, node: NodeRef, loading_class: Option<&str>) { + let lock = Attribute::new(PHX_REF_LOCK, Some(self.next_id().to_string())); + + self.document + .inner() + .lock() + .expect("lock poison") + .add_attribute(node, lock); + + let el_lock = Attribute::new(PHX_REF_SRC, Some(node.0.to_string())); + + self.document + .inner() + .lock() + .expect("lock poison") + .add_attribute(node, el_lock); + + if let Some(attr) = loading_class { + self.document + .inner() + .lock() + .expect("lock poison") + .extend_class_list(node, &[attr]); + } } } @@ -114,24 +158,6 @@ impl LiveChannel { self.document.set_event_handler(handler); } - pub async fn send_event( - &self, - event: PhxEvent, - payload: &str, - sender: &NodeRef, - ) -> Result { - todo!(); - } - - pub async fn send_event_json( - &self, - event: PhxEvent, - payload: JSON, - sender: &NodeRef, - ) -> Result { - todo!(); - } - pub fn get_phx_upload_id(&self, phx_target_name: &str) -> Result { // find the upload with target equal to phx_target_name // retrieve the security token diff --git a/crates/core/src/live_socket/mod.rs b/crates/core/src/live_socket/mod.rs index c2451f05..dea4bb15 100644 --- a/crates/core/src/live_socket/mod.rs +++ b/crates/core/src/live_socket/mod.rs @@ -1,4 +1,5 @@ mod channel; +pub(crate) mod dom_locking; mod error; mod navigation; mod protocol; diff --git a/crates/core/src/live_socket/protocol/event.rs b/crates/core/src/live_socket/protocol/event.rs index 3b5f1205..5f600b08 100644 --- a/crates/core/src/live_socket/protocol/event.rs +++ b/crates/core/src/live_socket/protocol/event.rs @@ -1,6 +1,8 @@ use std::borrow::Cow; -#[derive(uniffi::Enum, Clone)] +use phoenix_channels_client::Event; + +#[derive(uniffi::Enum, Clone, Debug)] pub enum PhxEvent { Other(String), PhxValue(String), @@ -35,8 +37,16 @@ pub enum PhxEvent { PhxTrackStatic, } +impl From<&PhxEvent> for Event { + fn from(value: &PhxEvent) -> Self { + Event::User { + user: value.str_name().to_string(), + } + } +} + impl PhxEvent { - fn str_name<'a>(&'a self) -> Cow<'a, str> { + pub fn str_name<'a>(&'a self) -> Cow<'a, str> { match self { PhxEvent::Other(o) => Cow::Borrowed(o.as_str()), PhxEvent::PhxValue(var_name) => ["phx-value-", var_name.as_str()].concat().into(), @@ -72,7 +82,7 @@ impl PhxEvent { } } - fn loading_attr(&self) -> Option<&str> { + pub fn loading_attr(&self) -> Option<&str> { match self { PhxEvent::PhxClick => Some("phx-click-loading"), PhxEvent::PhxChange => Some("phx-change-loading"), diff --git a/crates/core/src/live_socket/protocol/mod.rs b/crates/core/src/live_socket/protocol/mod.rs index 53f11265..31ab4c63 100644 --- a/crates/core/src/live_socket/protocol/mod.rs +++ b/crates/core/src/live_socket/protocol/mod.rs @@ -1 +1,39 @@ pub mod event; + +use super::{LiveChannel, LiveSocketError}; +use crate::dom::NodeRef; +use event::PhxEvent; +use phoenix_channels_client::{Event, Payload, JSON}; + +#[cfg_attr(not(target_family = "wasm"), uniffi::export(async_runtime = "tokio"))] +impl LiveChannel { + pub async fn send_event( + &self, + event: PhxEvent, + payload: String, + sender: &NodeRef, + ) -> Result { + let val = JSON::deserialize(payload)?; + self.send_event_json(event, val, sender).await + } + + pub async fn send_event_json( + &self, + event: PhxEvent, + payload: JSON, + sender: &NodeRef, + ) -> Result { + let change_attrs = event.loading_attr(); + + self.lock_node(*sender, change_attrs); + + let user_event = Event::from(&event); + let payload = Payload::JSONPayload { json: payload }; + + let res = self.channel.call(user_event, payload, self.timeout).await; + + self.unlock_node(*sender, change_attrs); + + Ok(res?) + } +} diff --git a/crates/core/src/live_socket/socket.rs b/crates/core/src/live_socket/socket.rs index 73fe8c0c..c8a1a9aa 100644 --- a/crates/core/src/live_socket/socket.rs +++ b/crates/core/src/live_socket/socket.rs @@ -416,15 +416,16 @@ impl LiveSocket { .await?; debug!("Created channel for live reload socket"); let join_payload = channel.join(self.timeout()).await?; - let document = Document::empty(); + let mut document = Document::empty(); + document.set_document_change_hooks(); Ok(LiveChannel { + current_lock_id: Default::default(), channel, join_payload, socket: self.socket(), document: document.into(), timeout: self.timeout(), - locks: Default::default(), }) } @@ -522,6 +523,7 @@ impl LiveSocket { let root: Root = root.try_into()?; let rendered: String = root.clone().try_into()?; let mut document = crate::parser::parse(&rendered)?; + document.set_document_change_hooks(); document.fragment_template = Some(root); Some(document) } else { @@ -533,12 +535,12 @@ impl LiveSocket { .ok_or(LiveSocketError::NoDocumentInJoinPayload)?; Ok(LiveChannel { + current_lock_id: Default::default(), channel, join_payload, socket: self.socket(), document: document.into(), timeout: self.timeout(), - locks: Default::default(), }) } From 44d496a4d829f05c810e918acb42a1edb6282ebc Mon Sep 17 00:00:00 2001 From: mobile-bungalow Date: Thu, 5 Dec 2024 14:56:59 -0800 Subject: [PATCH 11/21] added test --- .../core/src/live_socket/dom_locking/mod.rs | 35 +++++++++++++++++ crates/core/src/live_socket/tests/mod.rs | 39 +++++++++++++++++++ .../test_server_web/live/thermostat_live.ex | 4 +- 3 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 crates/core/src/live_socket/dom_locking/mod.rs diff --git a/crates/core/src/live_socket/dom_locking/mod.rs b/crates/core/src/live_socket/dom_locking/mod.rs new file mode 100644 index 00000000..0c53f1c1 --- /dev/null +++ b/crates/core/src/live_socket/dom_locking/mod.rs @@ -0,0 +1,35 @@ +use crate::dom::{Document, NodeRef}; + +pub const PHX_REF_LOCK: &str = "data-phx-ref"; +pub const PHX_REF_SRC: &str = "data-phx-ref-src"; + +/// Speculative action taken before a patch +#[derive(Debug)] +pub enum BeforePatch { + /// A new node would be added to `parent`. + WouldAdd { parent: NodeRef }, + /// The node will be removed from `parent` + WouldRemove { node: NodeRef }, + /// The node will be modified + WouldChange { node: NodeRef }, + /// The node will be replaced + WouldReplace { node: NodeRef }, +} + +/// Applications specific dom morphing hooks. +/// These functions implement application specific +#[derive(Debug, Clone, Default)] +pub struct PhxDocumentChangeHooks; + +impl PhxDocumentChangeHooks { + /// If a patch result would touch a part of the locked tree, return true. + /// These changes are not kept in the DOM but instead in the root fragment. + pub fn can_complete_change(&self, doc: &Document, patch: &BeforePatch) -> bool { + match patch { + BeforePatch::WouldAdd { parent } => !doc.get(*parent).has_attribute(PHX_REF_LOCK, None), + BeforePatch::WouldChange { node } => !doc.get(*node).has_attribute(PHX_REF_LOCK, None), + BeforePatch::WouldRemove { node } => !doc.get(*node).has_attribute(PHX_REF_LOCK, None), + BeforePatch::WouldReplace { node } => !doc.get(*node).has_attribute(PHX_REF_LOCK, None), + } + } +} diff --git a/crates/core/src/live_socket/tests/mod.rs b/crates/core/src/live_socket/tests/mod.rs index 3831f3c6..444d106b 100644 --- a/crates/core/src/live_socket/tests/mod.rs +++ b/crates/core/src/live_socket/tests/mod.rs @@ -66,6 +66,45 @@ async fn join_live_view() { .expect("Failed to join channel"); } +#[tokio::test] +async fn click_test() { + let _ = env_logger::builder() + .parse_default_env() + .is_test(true) + .try_init(); + + let url = format!("http://{HOST}/thermostat"); + + let live_socket = LiveSocket::new(url.to_string(), "swiftui".into(), Default::default()) + .await + .expect("Failed to get liveview socket"); + + let live_channel = live_socket + .join_liveview_channel(None, None) + .await + .expect("Failed to join channel"); + + let join_doc = live_channel + .join_document() + .expect("Failed to render join payload"); + + let expected = r#" + + + Current temperature: 70°F + + +"#; + + assert_doc_eq!(expected, join_doc.to_string()); + + let sender = join_doc.get_by_id("button"); + + //live_channel.send_event(event, payload, sender) +} + #[tokio::test] async fn channels_keep_listening_for_diffs_on_reconnect() { let _ = env_logger::builder() diff --git a/tests/support/test_server/lib/test_server_web/live/thermostat_live.ex b/tests/support/test_server/lib/test_server_web/live/thermostat_live.ex index 150b5d76..4c6d679f 100644 --- a/tests/support/test_server/lib/test_server_web/live/thermostat_live.ex +++ b/tests/support/test_server/lib/test_server_web/live/thermostat_live.ex @@ -40,7 +40,7 @@ defmodule TestServerWeb.ThermostatLive.SwiftUI do Current temperature: <%= @temperature %>°F - + """ end @@ -56,7 +56,7 @@ defmodule TestServerWeb.ThermostatLive.Jetpack do Current temperature: <%= @temperature %>°F - + """ From db50cd165032de1fc0a1d91855142138d81bfe32 Mon Sep 17 00:00:00 2001 From: mobile-bungalow Date: Thu, 5 Dec 2024 16:24:37 -0800 Subject: [PATCH 12/21] test initially passing --- crates/core/src/diff/fragment/merge.rs | 2 +- crates/core/src/diff/patch.rs | 20 ++--- crates/core/src/dom/ffi.rs | 9 +- crates/core/src/dom/mod.rs | 16 +++- crates/core/src/live_socket/channel.rs | 11 +-- .../core/src/live_socket/dom_locking/mod.rs | 4 +- crates/core/src/live_socket/error.rs | 2 + crates/core/src/live_socket/protocol/event.rs | 83 ++++++++++--------- crates/core/src/live_socket/protocol/mod.rs | 38 +++++++-- crates/core/src/live_socket/tests/mod.rs | 21 ++++- 10 files changed, 131 insertions(+), 75 deletions(-) diff --git a/crates/core/src/diff/fragment/merge.rs b/crates/core/src/diff/fragment/merge.rs index fdb2b316..2bee3b54 100644 --- a/crates/core/src/diff/fragment/merge.rs +++ b/crates/core/src/diff/fragment/merge.rs @@ -102,7 +102,7 @@ pub struct ResolveCtx<'a> { new_components: &'a HashMap, } -impl<'a> ResolveCtx<'a> { +impl ResolveCtx<'_> { pub fn get(&self, cid: i32) -> Result<&Component, MergeError> { let old_id = cid < 0; let abs_id = cid.abs().to_string(); diff --git a/crates/core/src/diff/patch.rs b/crates/core/src/diff/patch.rs index 4dec3710..c90f5188 100644 --- a/crates/core/src/diff/patch.rs +++ b/crates/core/src/diff/patch.rs @@ -128,7 +128,7 @@ impl Patch { Self::InsertBefore { before, node: data } => { let d = doc.document(); let parent = d.parent(before).expect("inserted node should have parent"); - let speculative = BeforePatch::WouldAdd { parent }; + let speculative = BeforePatch::Add { parent }; if doc.document().can_complete_change(&speculative) { let node = doc.insert_before(data.clone(), before); @@ -143,7 +143,7 @@ impl Patch { .parent(after) .expect("inserted node should have parent"); - let speculative = BeforePatch::WouldAdd { parent }; + let speculative = BeforePatch::Add { parent }; if doc.document().can_complete_change(&speculative) { let node = doc.insert_after(data.clone(), after); @@ -198,7 +198,7 @@ impl Patch { let d = doc.document_mut(); let parent = d.parent(before).expect("inserted node should have parent"); - let speculative = BeforePatch::WouldAdd { parent }; + let speculative = BeforePatch::Add { parent }; if d.can_complete_change(&speculative) { d.insert_before(node, before); @@ -217,7 +217,7 @@ impl Patch { }) } Self::AppendTo { parent, node: data } => { - let speculative = BeforePatch::WouldAdd { parent }; + let speculative = BeforePatch::Add { parent }; if doc.document().can_complete_change(&speculative) { let node = doc.append_child(parent, data.clone()); @@ -230,7 +230,7 @@ impl Patch { let d = doc.document_mut(); let parent = d.parent(after).expect("inserted node should have parent"); - let speculative = BeforePatch::WouldAdd { parent }; + let speculative = BeforePatch::Add { parent }; if d.can_complete_change(&speculative) { let node = stack.pop().unwrap(); @@ -245,14 +245,8 @@ impl Patch { let data = doc.document().get(node).clone(); let parent = doc.document_mut().parent(node); - let can_remove = if let Some(parent) = parent { - let speculative = BeforePatch::WouldRemove { node }; - doc.document().can_complete_change(&speculative) - } else { - false - }; - - if can_remove { + let speculative = BeforePatch::WouldRemove { node }; + if doc.document().can_complete_change(&speculative) { doc.remove(node); parent.map(|parent| PatchResult::Remove { node, parent, data }) } else { diff --git a/crates/core/src/dom/ffi.rs b/crates/core/src/dom/ffi.rs index d9235681..14c0369b 100644 --- a/crates/core/src/dom/ffi.rs +++ b/crates/core/src/dom/ffi.rs @@ -141,7 +141,14 @@ impl Document { .lock() .expect("lock poisoned!") .attributes(*node_ref) - .to_vec() + .to_owned() + } + + pub fn get_attribute_by_name(&self, node_ref: Arc, name: &str) -> Option { + self.inner + .lock() + .expect("lock poisoned!") + .get_attribute_by_name(*node_ref, name) } pub fn get(&self, node_ref: Arc) -> NodeData { diff --git a/crates/core/src/dom/mod.rs b/crates/core/src/dom/mod.rs index c88c460a..d9e87de6 100644 --- a/crates/core/src/dom/mod.rs +++ b/crates/core/src/dom/mod.rs @@ -243,10 +243,10 @@ impl Document { } /// Returns the set of attribute refs associated with `node` - pub fn attributes(&self, node: NodeRef) -> Vec { + pub fn attributes(&self, node: NodeRef) -> &[Attribute] { match &self.nodes[node] { - NodeData::NodeElement { element: ref elem } => elem.attributes.clone(), - _ => vec![], + NodeData::NodeElement { element: ref elem } => &elem.attributes, + _ => &[], } } @@ -551,6 +551,8 @@ impl Document { } } + /// Remove all classes from the class list of `node` for which the + /// predicate returns `false` pub fn remove_classes_by

(&mut self, node: NodeRef, mut predicate: P) where P: FnMut(&str) -> bool, @@ -559,6 +561,7 @@ impl Document { return; }; + let mut empty = false; if let Some(class_attr) = element .attributes .iter_mut() @@ -567,11 +570,16 @@ impl Document { if let Some(value) = &mut class_attr.value { *value = value .split_whitespace() - .filter(|&class| !predicate(class)) + .filter(|&class| predicate(class)) .collect::>() .join(" "); + empty = value.is_empty(); } } + + if empty { + element.remove_attribute(&AttributeName::new("class")); + } } pub fn add_attribute(&mut self, node: NodeRef, attribute: Attribute) { diff --git a/crates/core/src/live_socket/channel.rs b/crates/core/src/live_socket/channel.rs index 113dafee..5fa79c17 100644 --- a/crates/core/src/live_socket/channel.rs +++ b/crates/core/src/live_socket/channel.rs @@ -1,14 +1,12 @@ use futures::{future::FutureExt, pin_mut, select}; use std::{ - collections::HashSet, - sync::{atomic::AtomicU64, Arc, Mutex}, + sync::{Arc, Mutex}, time::Duration, }; use super::{ dom_locking::{self, PHX_REF_LOCK, PHX_REF_SRC}, - protocol::event::PhxEvent, LiveSocketError, UploadConfig, UploadError, }; use crate::{ @@ -106,14 +104,17 @@ impl LiveChannel { .inner() .lock() .expect("lock poison") - .remove_attributes_by(node, |attr| attr.name.name == dom_locking::PHX_REF_LOCK); + .remove_attributes_by(node, |attr| { + attr.name.name != dom_locking::PHX_REF_LOCK + && attr.name.name != dom_locking::PHX_REF_SRC + }); if let Some(loading_class) = loading_class { self.document .inner() .lock() .expect("lock poison") - .remove_classes_by(node, |class| class == loading_class); + .remove_classes_by(node, |class| class != loading_class); } } diff --git a/crates/core/src/live_socket/dom_locking/mod.rs b/crates/core/src/live_socket/dom_locking/mod.rs index 0c53f1c1..362933ec 100644 --- a/crates/core/src/live_socket/dom_locking/mod.rs +++ b/crates/core/src/live_socket/dom_locking/mod.rs @@ -7,7 +7,7 @@ pub const PHX_REF_SRC: &str = "data-phx-ref-src"; #[derive(Debug)] pub enum BeforePatch { /// A new node would be added to `parent`. - WouldAdd { parent: NodeRef }, + Add { parent: NodeRef }, /// The node will be removed from `parent` WouldRemove { node: NodeRef }, /// The node will be modified @@ -26,7 +26,7 @@ impl PhxDocumentChangeHooks { /// These changes are not kept in the DOM but instead in the root fragment. pub fn can_complete_change(&self, doc: &Document, patch: &BeforePatch) -> bool { match patch { - BeforePatch::WouldAdd { parent } => !doc.get(*parent).has_attribute(PHX_REF_LOCK, None), + BeforePatch::Add { parent } => !doc.get(*parent).has_attribute(PHX_REF_LOCK, None), BeforePatch::WouldChange { node } => !doc.get(*node).has_attribute(PHX_REF_LOCK, None), BeforePatch::WouldRemove { node } => !doc.get(*node).has_attribute(PHX_REF_LOCK, None), BeforePatch::WouldReplace { node } => !doc.get(*node).has_attribute(PHX_REF_LOCK, None), diff --git a/crates/core/src/live_socket/error.rs b/crates/core/src/live_socket/error.rs index efefc57d..672aa612 100644 --- a/crates/core/src/live_socket/error.rs +++ b/crates/core/src/live_socket/error.rs @@ -11,6 +11,8 @@ use crate::{ #[derive(Debug, thiserror::Error, uniffi::Error)] pub enum LiveSocketError { + #[error("Attempted to send event from a node missing the phx-{0} hook attribute.")] + MissingEventAttribtue(String), #[error("Internal Socket Locks would block.")] WouldLock, #[error("Internal Socket Locks poisoned.")] diff --git a/crates/core/src/live_socket/protocol/event.rs b/crates/core/src/live_socket/protocol/event.rs index 5f600b08..7607dc5d 100644 --- a/crates/core/src/live_socket/protocol/event.rs +++ b/crates/core/src/live_socket/protocol/event.rs @@ -1,6 +1,13 @@ use std::borrow::Cow; -use phoenix_channels_client::Event; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub struct UserEvent { + pub r#type: String, + pub event: String, + pub value: serde_json::Value, +} #[derive(uniffi::Enum, Clone, Debug)] pub enum PhxEvent { @@ -37,51 +44,47 @@ pub enum PhxEvent { PhxTrackStatic, } -impl From<&PhxEvent> for Event { - fn from(value: &PhxEvent) -> Self { - Event::User { - user: value.str_name().to_string(), - } - } -} - impl PhxEvent { - pub fn str_name<'a>(&'a self) -> Cow<'a, str> { + pub fn type_name(&self) -> Cow<'_, str> { match self { PhxEvent::Other(o) => Cow::Borrowed(o.as_str()), - PhxEvent::PhxValue(var_name) => ["phx-value-", var_name.as_str()].concat().into(), - PhxEvent::PhxClick => "phx-click".into(), - PhxEvent::PhxClickAway => "phx-click-away".into(), - PhxEvent::PhxChange => "phx-change".into(), - PhxEvent::PhxSubmit => "phx-submit".into(), - PhxEvent::PhxFeedbackFor => "phx-feedback-for".into(), - PhxEvent::PhxFeedbackGroup => "phx-feedback-group".into(), - PhxEvent::PhxDisableWith => "phx-disable-with".into(), - PhxEvent::PhxTriggerAction => "phx-trigger-action".into(), - PhxEvent::PhxAutoRecover => "phx-auto-recover".into(), - PhxEvent::PhxBlur => "phx-blur".into(), - PhxEvent::PhxFocus => "phx-focus".into(), - PhxEvent::PhxWindowBlur => "phx-window-blur".into(), - PhxEvent::PhxWindowFocus => "phx-window-focus".into(), - PhxEvent::PhxKeydown => "phx-keydown".into(), - PhxEvent::PhxKeyup => "phx-keyup".into(), - PhxEvent::PhxWindowKeydown => "phx-window-keydown".into(), - PhxEvent::PhxWindowKeyup => "phx-window-keyup".into(), - PhxEvent::PhxKey => "phx-key".into(), - PhxEvent::PhxViewportTop => "phx-viewport-top".into(), - PhxEvent::PhxViewportBottom => "phx-viewport-bottom".into(), - PhxEvent::PhxMounted => "phx-mounted".into(), - PhxEvent::PhxUpdate => "phx-update".into(), - PhxEvent::PhxRemove => "phx-remove".into(), - PhxEvent::PhxHook => "phx-hook".into(), - PhxEvent::PhxConnected => "phx-connected".into(), - PhxEvent::PhxDisconnected => "phx-disconnected".into(), - PhxEvent::PhxDebounce => "phx-debounce".into(), - PhxEvent::PhxThrottle => "phx-throttle".into(), - PhxEvent::PhxTrackStatic => "phx-track-static".into(), + PhxEvent::PhxValue(var_name) => ["value-", var_name.as_str()].concat().into(), + PhxEvent::PhxClick => "click".into(), + PhxEvent::PhxClickAway => "click-away".into(), + PhxEvent::PhxChange => "change".into(), + PhxEvent::PhxSubmit => "submit".into(), + PhxEvent::PhxFeedbackFor => "feedback-for".into(), + PhxEvent::PhxFeedbackGroup => "feedback-group".into(), + PhxEvent::PhxDisableWith => "disable-with".into(), + PhxEvent::PhxTriggerAction => "trigger-action".into(), + PhxEvent::PhxAutoRecover => "auto-recover".into(), + PhxEvent::PhxBlur => "blur".into(), + PhxEvent::PhxFocus => "focus".into(), + PhxEvent::PhxWindowBlur => "window-blur".into(), + PhxEvent::PhxWindowFocus => "window-focus".into(), + PhxEvent::PhxKeydown => "keydown".into(), + PhxEvent::PhxKeyup => "keyup".into(), + PhxEvent::PhxWindowKeydown => "window-keydown".into(), + PhxEvent::PhxWindowKeyup => "window-keyup".into(), + PhxEvent::PhxKey => "key".into(), + PhxEvent::PhxViewportTop => "viewport-top".into(), + PhxEvent::PhxViewportBottom => "viewport-bottom".into(), + PhxEvent::PhxMounted => "mounted".into(), + PhxEvent::PhxUpdate => "update".into(), + PhxEvent::PhxRemove => "remove".into(), + PhxEvent::PhxHook => "hook".into(), + PhxEvent::PhxConnected => "connected".into(), + PhxEvent::PhxDisconnected => "disconnected".into(), + PhxEvent::PhxDebounce => "debounce".into(), + PhxEvent::PhxThrottle => "throttle".into(), + PhxEvent::PhxTrackStatic => "track-static".into(), } } + pub fn phx_attribute(&self) -> String { + format!("phx-{}", self.type_name()) + } + pub fn loading_attr(&self) -> Option<&str> { match self { PhxEvent::PhxClick => Some("phx-click-loading"), diff --git a/crates/core/src/live_socket/protocol/mod.rs b/crates/core/src/live_socket/protocol/mod.rs index 31ab4c63..43cea771 100644 --- a/crates/core/src/live_socket/protocol/mod.rs +++ b/crates/core/src/live_socket/protocol/mod.rs @@ -2,7 +2,7 @@ pub mod event; use super::{LiveChannel, LiveSocketError}; use crate::dom::NodeRef; -use event::PhxEvent; +use event::{PhxEvent, UserEvent}; use phoenix_channels_client::{Event, Payload, JSON}; #[cfg_attr(not(target_family = "wasm"), uniffi::export(async_runtime = "tokio"))] @@ -10,25 +10,51 @@ impl LiveChannel { pub async fn send_event( &self, event: PhxEvent, - payload: String, + value: Option, sender: &NodeRef, ) -> Result { - let val = JSON::deserialize(payload)?; + let val = value.map(JSON::deserialize).transpose()?; self.send_event_json(event, val, sender).await } pub async fn send_event_json( &self, event: PhxEvent, - payload: JSON, + value: Option, sender: &NodeRef, ) -> Result { + let r#type = event.type_name().into(); let change_attrs = event.loading_attr(); + let event = self + .document + .inner() + .lock() + .expect("lock poison") + .get_attribute_by_name(*sender, event.phx_attribute().as_str()) + .and_then(|attr| attr.value) + .ok_or(LiveSocketError::MissingEventAttribtue( + event.type_name().to_string(), + ))?; + + let default = serde_json::Value::Object(serde_json::Map::new()); + let payload = UserEvent { + r#type, + event, + value: value.map(serde_json::Value::from).unwrap_or(default), + }; + + let val = serde_json::to_value(payload)?; + self.lock_node(*sender, change_attrs); - let user_event = Event::from(&event); - let payload = Payload::JSONPayload { json: payload }; + let user_event = Event::User { + user: "event".into(), + }; + + let payload = Payload::JSONPayload { + json: JSON::from(val), + }; let res = self.channel.call(user_event, payload, self.timeout).await; diff --git a/crates/core/src/live_socket/tests/mod.rs b/crates/core/src/live_socket/tests/mod.rs index 444d106b..e3e6ea37 100644 --- a/crates/core/src/live_socket/tests/mod.rs +++ b/crates/core/src/live_socket/tests/mod.rs @@ -51,7 +51,7 @@ async fn join_live_view() { let join_doc = live_channel .join_document() .expect("Failed to render join payload"); - let rendered = format!("{}", join_doc.to_string()); + let rendered = format!("{}", join_doc); let expected = r#" @@ -100,9 +100,24 @@ async fn click_test() { assert_doc_eq!(expected, join_doc.to_string()); - let sender = join_doc.get_by_id("button"); + let sender = join_doc.get_by_id("button").expect("nothing by that name"); - //live_channel.send_event(event, payload, sender) + live_channel + .send_event_json(protocol::event::PhxEvent::PhxClick, None, &sender) + .await + .expect("click failed"); + + let expected = r#" + + + Current temperature: 70°F + + +"#; + + assert_doc_eq!(expected, live_channel.document().to_string()); } #[tokio::test] From 193445869313295f95a92638ef8165fac8eb9b0c Mon Sep 17 00:00:00 2001 From: mobile-bungalow Date: Thu, 5 Dec 2024 16:24:54 -0800 Subject: [PATCH 13/21] clippy --- crates/core/src/dom/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/core/src/dom/mod.rs b/crates/core/src/dom/mod.rs index d9e87de6..61f1f6cf 100644 --- a/crates/core/src/dom/mod.rs +++ b/crates/core/src/dom/mod.rs @@ -542,7 +542,7 @@ impl Document { let new_classes = classes.join(" "); match &mut class_attr.value { Some(existing) => { - existing.push_str(" "); + existing.push(' '); existing.push_str(&new_classes); } None => { From c7919a6392a9e3ed21a3b14a890eb5ee703d71b3 Mon Sep 17 00:00:00 2001 From: mobile-bungalow Date: Thu, 5 Dec 2024 16:24:59 -0800 Subject: [PATCH 14/21] clippy --- crates/core/src/live_socket/dom_locking/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/core/src/live_socket/dom_locking/mod.rs b/crates/core/src/live_socket/dom_locking/mod.rs index 362933ec..0c53f1c1 100644 --- a/crates/core/src/live_socket/dom_locking/mod.rs +++ b/crates/core/src/live_socket/dom_locking/mod.rs @@ -7,7 +7,7 @@ pub const PHX_REF_SRC: &str = "data-phx-ref-src"; #[derive(Debug)] pub enum BeforePatch { /// A new node would be added to `parent`. - Add { parent: NodeRef }, + WouldAdd { parent: NodeRef }, /// The node will be removed from `parent` WouldRemove { node: NodeRef }, /// The node will be modified @@ -26,7 +26,7 @@ impl PhxDocumentChangeHooks { /// These changes are not kept in the DOM but instead in the root fragment. pub fn can_complete_change(&self, doc: &Document, patch: &BeforePatch) -> bool { match patch { - BeforePatch::Add { parent } => !doc.get(*parent).has_attribute(PHX_REF_LOCK, None), + BeforePatch::WouldAdd { parent } => !doc.get(*parent).has_attribute(PHX_REF_LOCK, None), BeforePatch::WouldChange { node } => !doc.get(*node).has_attribute(PHX_REF_LOCK, None), BeforePatch::WouldRemove { node } => !doc.get(*node).has_attribute(PHX_REF_LOCK, None), BeforePatch::WouldReplace { node } => !doc.get(*node).has_attribute(PHX_REF_LOCK, None), From c5e9d0aedad89cfc6f31ef4b7d965a35e37574b5 Mon Sep 17 00:00:00 2001 From: mobile-bungalow Date: Thu, 5 Dec 2024 16:26:42 -0800 Subject: [PATCH 15/21] clippy --- crates/core/src/diff/patch.rs | 14 +++++++------- crates/core/src/live_socket/dom_locking/mod.rs | 16 ++++++++-------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/crates/core/src/diff/patch.rs b/crates/core/src/diff/patch.rs index c90f5188..802dc376 100644 --- a/crates/core/src/diff/patch.rs +++ b/crates/core/src/diff/patch.rs @@ -245,7 +245,7 @@ impl Patch { let data = doc.document().get(node).clone(); let parent = doc.document_mut().parent(node); - let speculative = BeforePatch::WouldRemove { node }; + let speculative = BeforePatch::Remove { node }; if doc.document().can_complete_change(&speculative) { doc.remove(node); parent.map(|parent| PatchResult::Remove { node, parent, data }) @@ -257,7 +257,7 @@ impl Patch { let data = doc.document().get(node).clone(); let parent = doc.document_mut().parent(node)?; - let speculative = BeforePatch::WouldReplace { node }; + let speculative = BeforePatch::Replace { node }; if doc.document().can_complete_change(&speculative) { doc.replace(node, replacement); @@ -270,7 +270,7 @@ impl Patch { doc.set_attribute(name, value); let node = doc.insertion_point(); - let speculative = BeforePatch::WouldChange { node }; + let speculative = BeforePatch::Change { node }; if doc.document().can_complete_change(&speculative) { let data = doc.document().get(node).clone(); @@ -280,7 +280,7 @@ impl Patch { } } Self::AddAttributeTo { node, name, value } => { - let speculative = BeforePatch::WouldChange { node }; + let speculative = BeforePatch::Change { node }; if doc.document().can_complete_change(&speculative) { let data = doc.document().get(node).clone(); let mut guard = doc.insert_guard(); @@ -292,7 +292,7 @@ impl Patch { } } Self::UpdateAttribute { node, name, value } => { - let speculative = BeforePatch::WouldChange { node }; + let speculative = BeforePatch::Change { node }; if doc.document().can_complete_change(&speculative) { let data = doc.document().get(node).clone(); let mut guard = doc.insert_guard(); @@ -304,7 +304,7 @@ impl Patch { } } Self::RemoveAttributeByName { node, name } => { - let speculative = BeforePatch::WouldChange { node }; + let speculative = BeforePatch::Change { node }; if doc.document().can_complete_change(&speculative) { let data = doc.document().get(node).clone(); let mut guard = doc.insert_guard(); @@ -316,7 +316,7 @@ impl Patch { } } Self::SetAttributes { node, attributes } => { - let speculative = BeforePatch::WouldChange { node }; + let speculative = BeforePatch::Change { node }; if doc.document().can_complete_change(&speculative) { let data = doc.document().get(node).clone(); let mut guard = doc.insert_guard(); diff --git a/crates/core/src/live_socket/dom_locking/mod.rs b/crates/core/src/live_socket/dom_locking/mod.rs index 0c53f1c1..57e58eb7 100644 --- a/crates/core/src/live_socket/dom_locking/mod.rs +++ b/crates/core/src/live_socket/dom_locking/mod.rs @@ -7,13 +7,13 @@ pub const PHX_REF_SRC: &str = "data-phx-ref-src"; #[derive(Debug)] pub enum BeforePatch { /// A new node would be added to `parent`. - WouldAdd { parent: NodeRef }, + Add { parent: NodeRef }, /// The node will be removed from `parent` - WouldRemove { node: NodeRef }, + Remove { node: NodeRef }, /// The node will be modified - WouldChange { node: NodeRef }, + Change { node: NodeRef }, /// The node will be replaced - WouldReplace { node: NodeRef }, + Replace { node: NodeRef }, } /// Applications specific dom morphing hooks. @@ -26,10 +26,10 @@ impl PhxDocumentChangeHooks { /// These changes are not kept in the DOM but instead in the root fragment. pub fn can_complete_change(&self, doc: &Document, patch: &BeforePatch) -> bool { match patch { - BeforePatch::WouldAdd { parent } => !doc.get(*parent).has_attribute(PHX_REF_LOCK, None), - BeforePatch::WouldChange { node } => !doc.get(*node).has_attribute(PHX_REF_LOCK, None), - BeforePatch::WouldRemove { node } => !doc.get(*node).has_attribute(PHX_REF_LOCK, None), - BeforePatch::WouldReplace { node } => !doc.get(*node).has_attribute(PHX_REF_LOCK, None), + BeforePatch::Add { parent } => !doc.get(*parent).has_attribute(PHX_REF_LOCK, None), + BeforePatch::Change { node } => !doc.get(*node).has_attribute(PHX_REF_LOCK, None), + BeforePatch::Remove { node } => !doc.get(*node).has_attribute(PHX_REF_LOCK, None), + BeforePatch::Replace { node } => !doc.get(*node).has_attribute(PHX_REF_LOCK, None), } } } From 6e54a30465c61be5f33292dd261718c5adabff06 Mon Sep 17 00:00:00 2001 From: mobile-bungalow Date: Fri, 6 Dec 2024 01:30:47 -0800 Subject: [PATCH 16/21] click test passing --- crates/core/src/dom/ffi.rs | 6 +-- crates/core/src/live_socket/channel.rs | 54 +++++++------------ crates/core/src/live_socket/mod.rs | 11 ++++ crates/core/src/live_socket/protocol/event.rs | 31 +++++++++++ crates/core/src/live_socket/protocol/mod.rs | 45 +++++++--------- crates/core/src/live_socket/socket.rs | 11 +--- crates/core/src/live_socket/tests/mod.rs | 2 +- 7 files changed, 85 insertions(+), 75 deletions(-) diff --git a/crates/core/src/dom/ffi.rs b/crates/core/src/dom/ffi.rs index 14c0369b..a401a7a1 100644 --- a/crates/core/src/dom/ffi.rs +++ b/crates/core/src/dom/ffi.rs @@ -67,7 +67,7 @@ impl Document { .user_event_callback = Some(Arc::from(handler)); } - pub fn merge_fragment_json_unserialized(&self, json: JSON) -> Result<(), RenderError> { + pub fn merge_fragment_json(&self, json: JSON) -> Result<(), RenderError> { let json = serde_json::Value::from(json); let results = self .inner @@ -105,9 +105,9 @@ impl Document { Ok(()) } - pub fn merge_fragment_json(&self, json: &str) -> Result<(), RenderError> { + pub fn merge_fragment_serialized(&self, json: &str) -> Result<(), RenderError> { let json = serde_json::from_str(json)?; - self.merge_fragment_json_unserialized(JSON::from(&json)) + self.merge_fragment_json(JSON::from(&json)) } pub fn next_upload_id(&self) -> u64 { diff --git a/crates/core/src/live_socket/channel.rs b/crates/core/src/live_socket/channel.rs index 5fa79c17..7348abcb 100644 --- a/crates/core/src/live_socket/channel.rs +++ b/crates/core/src/live_socket/channel.rs @@ -7,6 +7,8 @@ use std::{ use super::{ dom_locking::{self, PHX_REF_LOCK, PHX_REF_SRC}, + lock, + protocol::event::ServerEvent, LiveSocketError, UploadConfig, UploadError, }; use crate::{ @@ -93,6 +95,14 @@ impl LiveChannel { Ok(document) } + pub fn handle_server_event(&self, event: ServerEvent) -> Result<(), LiveSocketError> { + if let Some(diff) = event.diff { + self.document.merge_fragment_json(JSON::from(diff))?; + } + + Ok(()) + } + pub fn next_id(&self) -> u64 { let mut id = self.current_lock_id.lock().expect("lock_poison"); *id += 1; @@ -100,47 +110,27 @@ impl LiveChannel { } pub fn unlock_node(&self, node: NodeRef, loading_class: Option<&str>) { - self.document - .inner() - .lock() - .expect("lock poison") - .remove_attributes_by(node, |attr| { - attr.name.name != dom_locking::PHX_REF_LOCK - && attr.name.name != dom_locking::PHX_REF_SRC - }); + lock!(self.document.inner()).remove_attributes_by(node, |attr| { + attr.name.name != dom_locking::PHX_REF_LOCK + && attr.name.name != dom_locking::PHX_REF_SRC + }); if let Some(loading_class) = loading_class { - self.document - .inner() - .lock() - .expect("lock poison") - .remove_classes_by(node, |class| class != loading_class); + lock!(self.document.inner()).remove_classes_by(node, |class| class != loading_class); } } pub fn lock_node(&self, node: NodeRef, loading_class: Option<&str>) { let lock = Attribute::new(PHX_REF_LOCK, Some(self.next_id().to_string())); - self.document - .inner() - .lock() - .expect("lock poison") - .add_attribute(node, lock); + lock!(self.document.inner()).add_attribute(node, lock); let el_lock = Attribute::new(PHX_REF_SRC, Some(node.0.to_string())); - self.document - .inner() - .lock() - .expect("lock poison") - .add_attribute(node, el_lock); + lock!(self.document.inner()).add_attribute(node, el_lock); if let Some(attr) = loading_class { - self.document - .inner() - .lock() - .expect("lock poison") - .extend_class_list(node, &[attr]); + lock!(self.document.inner()).extend_class_list(node, &[attr]); } } } @@ -162,11 +152,7 @@ impl LiveChannel { pub fn get_phx_upload_id(&self, phx_target_name: &str) -> Result { // find the upload with target equal to phx_target_name // retrieve the security token - let node_ref = self - .document() - .inner() - .lock() - .expect("lock poison!") + let node_ref = lock!(self.document().inner()) .select(Selector::And( Box::new(Selector::Attribute(AttributeName { namespace: None, @@ -227,7 +213,7 @@ impl LiveChannel { debug!("PAYLOAD: {json:?}"); // This function merges and uses the event handler set in `set_event_handler` // which will call back into the Swift/Kotlin. - document.merge_fragment_json_unserialized(json)?; + document.merge_fragment_json(json)?; } } }; diff --git a/crates/core/src/live_socket/mod.rs b/crates/core/src/live_socket/mod.rs index dea4bb15..89f81ad4 100644 --- a/crates/core/src/live_socket/mod.rs +++ b/crates/core/src/live_socket/mod.rs @@ -8,8 +8,19 @@ mod socket; #[cfg(test)] mod tests; +#[macro_export] +macro_rules! lock { + ($mutex:expr) => { + $mutex.lock().expect("Failed to acquire lock") + }; + ($mutex:expr, $msg:expr) => { + $mutex.lock().expect($msg) + }; +} + pub use channel::LiveChannel; pub use error::{LiveSocketError, UploadError}; +pub(crate) use lock; pub use socket::LiveSocket; pub struct UploadConfig { diff --git a/crates/core/src/live_socket/protocol/event.rs b/crates/core/src/live_socket/protocol/event.rs index 7607dc5d..cb64ebd9 100644 --- a/crates/core/src/live_socket/protocol/event.rs +++ b/crates/core/src/live_socket/protocol/event.rs @@ -1,7 +1,13 @@ use std::borrow::Cow; +use phoenix_channels_client::{Event, Payload, JSON}; use serde::{Deserialize, Serialize}; +#[derive(Serialize, Deserialize)] +pub struct ServerEvent { + pub diff: Option, +} + #[derive(Serialize, Deserialize)] pub struct UserEvent { pub r#type: String, @@ -9,6 +15,31 @@ pub struct UserEvent { pub value: serde_json::Value, } +impl UserEvent { + pub fn new(r#type: String, event: String, value: Option) -> Self { + Self { + r#type, + event, + value: value + .map(serde_json::Value::from) + .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())), + } + } + + pub fn to_channel_message(self) -> (Event, Payload) { + let val = serde_json::to_value(self).expect("Failed to serialize UserEvent"); + + ( + Event::User { + user: "event".into(), + }, + Payload::JSONPayload { + json: JSON::from(val), + }, + ) + } +} + #[derive(uniffi::Enum, Clone, Debug)] pub enum PhxEvent { Other(String), diff --git a/crates/core/src/live_socket/protocol/mod.rs b/crates/core/src/live_socket/protocol/mod.rs index 43cea771..fdc11e18 100644 --- a/crates/core/src/live_socket/protocol/mod.rs +++ b/crates/core/src/live_socket/protocol/mod.rs @@ -2,8 +2,9 @@ pub mod event; use super::{LiveChannel, LiveSocketError}; use crate::dom::NodeRef; -use event::{PhxEvent, UserEvent}; -use phoenix_channels_client::{Event, Payload, JSON}; +use event::{PhxEvent, ServerEvent, UserEvent}; +use phoenix_channels_client::{Payload, JSON}; +use serde::Deserialize; #[cfg_attr(not(target_family = "wasm"), uniffi::export(async_runtime = "tokio"))] impl LiveChannel { @@ -23,42 +24,32 @@ impl LiveChannel { value: Option, sender: &NodeRef, ) -> Result { - let r#type = event.type_name().into(); - let change_attrs = event.loading_attr(); - - let event = self + let event_attr = self .document .inner() - .lock() - .expect("lock poison") + .try_lock()? .get_attribute_by_name(*sender, event.phx_attribute().as_str()) .and_then(|attr| attr.value) .ok_or(LiveSocketError::MissingEventAttribtue( event.type_name().to_string(), ))?; - let default = serde_json::Value::Object(serde_json::Map::new()); - let payload = UserEvent { - r#type, - event, - value: value.map(serde_json::Value::from).unwrap_or(default), - }; - - let val = serde_json::to_value(payload)?; - - self.lock_node(*sender, change_attrs); - - let user_event = Event::User { - user: "event".into(), - }; - - let payload = Payload::JSONPayload { - json: JSON::from(val), - }; + self.lock_node(*sender, event.loading_attr()); + let user_event = UserEvent::new(event.type_name().into(), event_attr, value); + let (user_event, payload) = user_event.to_channel_message(); let res = self.channel.call(user_event, payload, self.timeout).await; - self.unlock_node(*sender, change_attrs); + self.unlock_node(*sender, event.loading_attr()); + + if let Ok(Payload::JSONPayload { json }) = &res { + let val = serde_json::Value::from(json.clone()); + if let Ok(server_event) = ServerEvent::deserialize(val) { + self.handle_server_event(server_event)?; + } else { + log::error!("Could not convert response into server event!") + } + } Ok(res?) } diff --git a/crates/core/src/live_socket/socket.rs b/crates/core/src/live_socket/socket.rs index c8a1a9aa..dc2d4b00 100644 --- a/crates/core/src/live_socket/socket.rs +++ b/crates/core/src/live_socket/socket.rs @@ -10,6 +10,7 @@ use log::debug; use phoenix_channels_client::{url::Url, Number, Payload, Socket, Topic, JSON}; use reqwest::Method as ReqMethod; +use super::lock; pub use super::{LiveChannel, LiveSocketError}; use crate::{ @@ -18,16 +19,6 @@ use crate::{ parser::parse, }; -#[macro_export] -macro_rules! lock { - ($mutex:expr) => { - $mutex.lock().expect("Failed to acquire lock") - }; - ($mutex:expr, $msg:expr) => { - $mutex.lock().expect($msg) - }; -} - const LVN_VSN: &str = "2.0.0"; const LVN_VSN_KEY: &str = "vsn"; const CSRF_KEY: &str = "_csrf_token"; diff --git a/crates/core/src/live_socket/tests/mod.rs b/crates/core/src/live_socket/tests/mod.rs index e3e6ea37..2fe3a865 100644 --- a/crates/core/src/live_socket/tests/mod.rs +++ b/crates/core/src/live_socket/tests/mod.rs @@ -110,7 +110,7 @@ async fn click_test() { let expected = r#" - Current temperature: 70°F + Current temperature: 71°F