diff --git a/Cargo.lock b/Cargo.lock index 42aeb48a..8d165564 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 31a7af04..e1b1155b 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.42", features = ["full"] } env_logger = "0.11.1" diff --git a/crates/core/src/diff/patch.rs b/crates/core/src/diff/patch.rs index e78c262c..802dc376 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 { @@ -125,20 +126,31 @@ impl Patch { { match self { Self::InsertBefore { before, node: data } => { - let node = doc.insert_before(data.clone(), before); - let parent = doc - .document() - .parent(node) - .expect("inserted node should have parent"); - Some(PatchResult::Add { node, parent, data }) + let d = doc.document(); + let parent = d.parent(before).expect("inserted node should have parent"); + let speculative = BeforePatch::Add { 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::Add { 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 +195,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::Add { 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 +217,115 @@ impl Patch { }) } Self::AppendTo { parent, node: data } => { - let node = doc.append_child(parent, data.clone()); - Some(PatchResult::Add { node, parent, data }) + let speculative = BeforePatch::Add { 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::Add { 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 speculative = BeforePatch::Remove { node }; + if doc.document().can_complete_change(&speculative) { + 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::Replace { node }; + + 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::Change { 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::Change { 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::Change { 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::Change { 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::Change { 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..97f3359d 100644 --- a/crates/core/src/dom/attribute.rs +++ b/crates/core/src/dom/attribute.rs @@ -4,6 +4,28 @@ use smallstr::SmallString; use crate::InternedString; +#[macro_export] +macro_rules! attr { + ($name:literal) => { + Attribute::new($name, None) + }; + ($name:literal= $value:expr) => { + Attribute::new($name, Some($value.to_string())) + }; + ($namespace:literal : $name:literal) => { + Attribute { + name: AttributeName::new_with_namespace($namespace, $name), + value: None, + } + }; + ($namespace:literal : $name:literal = $value:expr) => { + Attribute { + name: AttributeName::new_with_namespace($namespace, $name), + value: Some($value.to_string()), + } + }; +} + /// Represents the fully-qualified name of an attribute #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, uniffi::Record)] pub struct AttributeName { @@ -11,6 +33,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 +85,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/ffi.rs b/crates/core/src/dom/ffi.rs index 9feb8913..d111ce62 100644 --- a/crates/core/src/dom/ffi.rs +++ b/crates/core/src/dom/ffi.rs @@ -3,13 +3,15 @@ use std::{ sync::{Arc, Mutex}, }; -use super::ChangeType; +use phoenix_channels_client::JSON; + pub use super::{ attribute::Attribute, node::{Node, NodeData, NodeRef}, printer::PrintOptions, DocumentChangeHandler, }; +use super::{AttributeName, ChangeType}; use crate::{ diff::{fragment::RenderError, PatchResult}, parser::ParseError, @@ -59,11 +61,14 @@ 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(&self, json: &str) -> Result<(), RenderError> { - let json = serde_json::from_str(json)?; + pub fn merge_fragment_json(&self, json: JSON) -> Result<(), RenderError> { + let json = serde_json::Value::from(json); let results = self .inner @@ -71,34 +76,32 @@ impl Document { .expect("lock poisoned!") .merge_fragment_json(json)?; - let Some(handler) = self - .inner - .lock() - .expect("lock poisoned") - .event_callback - .clone() - else { - return Ok(()); + self.handle_patch(results.into_iter()); + + Ok(()) + } + + /// Removes an attribute by name. Propogating events + /// and cancelling the transaction if it applies to a locked node + pub fn remove_attribute(&self, node: &NodeRef, name: AttributeName) { + let mut stack = vec![]; + + let res = { + // doc needs to drop before `handle_patch` + let mut doc = self.inner.lock().expect("lock poison"); + let mut editor = doc.edit(); + let res = crate::diff::Patch::RemoveAttributeByName { node: *node, name } + .apply(&mut editor, &mut stack); + editor.finish(); + res }; - for patch in results.into_iter() { - match patch { - PatchResult::Add { node, parent, data } => { - handler.handle(ChangeType::Add, node.into(), data, Some(parent.into())); - } - PatchResult::Remove { node, parent, data } => { - handler.handle(ChangeType::Remove, node.into(), data, Some(parent.into())); - } - PatchResult::Change { node, data } => { - handler.handle(ChangeType::Change, node.into(), data, None); - } - PatchResult::Replace { node, parent, data } => { - handler.handle(ChangeType::Replace, node.into(), data, Some(parent.into())); - } - } - } + self.handle_patch(res.into_iter()); + } - Ok(()) + pub fn merge_fragment_serialized(&self, json: &str) -> Result<(), RenderError> { + let json = serde_json::from_str(json)?; + self.merge_fragment_json(JSON::from(&json)) } pub fn next_upload_id(&self) -> u64 { @@ -132,7 +135,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 { @@ -152,6 +162,7 @@ impl Document { self.to_string() } } + impl Document { pub fn print_node( &self, @@ -164,6 +175,35 @@ impl Document { .map_err(|_| fmt::Error)? .print_node(node, writer, options) } + + pub fn handle_patch<'a, P: Iterator>(&self, patches: P) { + let Some(handler) = self + .inner + .lock() + .expect("lock poisoned") + .user_event_callback + .clone() + else { + return; + }; + + for patch in patches.into_iter() { + match patch { + PatchResult::Add { node, parent, data } => { + handler.handle(ChangeType::Add, node.into(), data, Some(parent.into())); + } + PatchResult::Remove { node, parent, data } => { + handler.handle(ChangeType::Remove, node.into(), data, Some(parent.into())); + } + PatchResult::Change { node, data } => { + handler.handle(ChangeType::Change, node.into(), data, None); + } + PatchResult::Replace { node, parent, data } => { + handler.handle(ChangeType::Replace, node.into(), data, Some(parent.into())); + } + } + } + } } impl fmt::Display for Document { diff --git a/crates/core/src/dom/mod.rs b/crates/core/src/dom/mod.rs index 87882c46..ec3fa67f 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, @@ -26,6 +28,7 @@ pub use self::{ printer::PrintOptions, select::{SelectionIter, Selector}, }; + use crate::{ diff::{ fragment::{FragmentMerge, RenderError, Root, RootDiff}, @@ -70,7 +73,12 @@ pub struct Document { root: NodeRef, /// The fragment template. pub fragment_template: Option, - pub event_callback: Option>, + /// Instruments all patch events which are applied to the document. + 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 @@ -133,11 +141,34 @@ impl Document { children: SecondaryMap::new(), ids: Default::default(), fragment_template: None, - event_callback: None, + user_event_callback: None, + 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) + } else { + true + } + } + + /// 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()) @@ -213,10 +244,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, + _ => &[], } } @@ -491,6 +522,76 @@ 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); + } + } + } + + /// 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, + { + let Some(NodeData::NodeElement { element }) = self.nodes.get_mut(node) else { + return; + }; + + let mut empty = false; + 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(" "); + empty = value.is_empty(); + } + } + + if empty { + element.remove_attribute(&AttributeName::new("class")); + } + } + + 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 @@ -541,18 +642,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)) @@ -612,7 +712,7 @@ impl Document { } #[repr(C)] -#[derive(Copy, Clone, uniffi::Enum)] +#[derive(Debug, PartialEq, Copy, Clone, uniffi::Enum)] pub enum ChangeType { Change = 0, Add = 1, @@ -917,6 +1017,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..a2509362 100644 --- a/crates/core/src/dom/node.rs +++ b/crates/core/src/dom/node.rs @@ -111,23 +111,40 @@ 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() } + 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}") } } 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 { @@ -156,6 +173,13 @@ impl NodeData { element: Element::new(tag.into()), } } + + #[inline] + pub fn leaf>(content: T) -> Self { + Self::Leaf { + value: content.into(), + } + } } impl From for NodeData { @@ -309,3 +333,34 @@ impl Element { } } } + +#[macro_export] +macro_rules! element { + ($name:expr) => { + NodeData::NodeElement { element: Element::new(ElementName::new($name)) } + }; + + ($full_name:expr) => { + NodeData::NodeElement { element: Element::new(ElementName::from($full_name)) } + }; + + ($name:expr, $($attr:expr),+ $(,)?) => { + { + let mut element = Element::new(ElementName::from($name)); + $( + element.attributes.push($attr); + )+ + NodeData::NodeElement { element } + } + }; + + ($full_name:expr, $($attr:expr),+ $(,)?) => { + { + let mut element = Element::new(ElementName::from($full_name)); + $( + element.attributes.push($attr); + )+ + NodeData::NodeElement { element } + } + }; +} 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 ea5cdbc2..7d0e0a2a 100644 --- a/crates/core/src/live_socket/channel.rs +++ b/crates/core/src/live_socket/channel.rs @@ -1,11 +1,21 @@ -use std::{sync::Arc, time::Duration}; +use futures::{future::FutureExt, pin_mut, select}; -use super::{LiveSocketError, UploadConfig, UploadError}; +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; + +use super::{ + dom_locking::{self, PHX_REF_LOCK, PHX_REF_SRC}, + lock, + protocol::event::ServerEvent, + LiveSocketError, UploadConfig, UploadError, +}; use crate::{ diff::fragment::{Root, RootDiff}, dom::{ ffi::{Document as FFiDocument, DocumentChangeHandler}, - AttributeName, AttributeValue, Document, Selector, + Attribute, AttributeName, AttributeValue, Document, NodeRef, Selector, }, parser::parse, }; @@ -14,6 +24,7 @@ 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, @@ -83,6 +94,54 @@ impl LiveChannel { debug!("Join payload render:\n{document}"); Ok(document) } + + /// When not handled by [Self::merge_diffs], payloads returned by [Self::call] are passed + /// here automatically. This is important because call responses can result in diffs which + /// should be applied to the internal root interpolation tree. + 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; + *id + } + + pub fn unlock_node(&self, node: NodeRef, loading_class: Option<&str>) { + lock!(self.document.inner()) + .remove_attributes_by(node, |attr| attr.name.name != dom_locking::PHX_REF_LOCK); + + // propogate events + self.document() + .remove_attribute(&node, AttributeName::new(PHX_REF_LOCK)); + + self.document() + .remove_attribute(&node, AttributeName::new(PHX_REF_SRC)); + + if let Some(loading_class) = loading_class { + self.document() + .remove_attribute(&node, AttributeName::new(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())); + + lock!(self.document.inner()).add_attribute(node, lock); + + let el_lock = Attribute::new(PHX_REF_SRC, Some(node.0.to_string())); + + lock!(self.document.inner()).add_attribute(node, el_lock); + + if let Some(attr) = loading_class { + lock!(self.document.inner()).extend_class_list(node, &[attr]); + } + } } #[cfg_attr(not(target_family = "wasm"), uniffi::export(async_runtime = "tokio"))] @@ -102,11 +161,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, @@ -143,28 +198,52 @@ 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)?; + } + } + }; + } + 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/dom_locking/mod.rs b/crates/core/src/live_socket/dom_locking/mod.rs new file mode 100644 index 00000000..57e58eb7 --- /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`. + Add { parent: NodeRef }, + /// The node will be removed from `parent` + Remove { node: NodeRef }, + /// The node will be modified + Change { node: NodeRef }, + /// The node will be replaced + Replace { 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::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), + } + } +} 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/mod.rs b/crates/core/src/live_socket/mod.rs index 25b66e22..89f81ad4 100644 --- a/crates/core/src/live_socket/mod.rs +++ b/crates/core/src/live_socket/mod.rs @@ -1,13 +1,26 @@ mod channel; +pub(crate) mod dom_locking; mod error; mod navigation; +mod protocol; 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/navigation/ffi.rs b/crates/core/src/live_socket/navigation/ffi.rs index 6800631e..ce0b3dcb 100644 --- a/crates/core/src/live_socket/navigation/ffi.rs +++ b/crates/core/src/live_socket/navigation/ffi.rs @@ -131,7 +131,7 @@ impl LiveSocket { let socket = Socket::spawn(websocket_url, Some(session_data.cookies.clone())).await?; self.socket() - .disconnect() + .shutdown() .await .map_err(|_| LiveSocketError::DisconnectionError)?; 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..c0ff7896 --- /dev/null +++ b/crates/core/src/live_socket/protocol/event.rs @@ -0,0 +1,135 @@ +use std::{borrow::Cow, collections::HashMap}; + +use phoenix_channels_client::{Event, Payload, JSON}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub struct ServerEvent { + /// Under the hood this is a [RootDiff] object. But it is just converted to a + /// json value again so it's not worth the serialization overhead. + pub diff: Option, + #[serde(flatten)] + pub misc: HashMap, +} + +#[derive(Serialize, Deserialize)] +pub struct UserEvent { + pub r#type: String, + pub event: String, + 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 into_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), + 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 { + pub fn type_name(&self) -> Cow<'_, str> { + match self { + PhxEvent::Other(o) => Cow::Borrowed(o.as_str()), + 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"), + 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..1eafb192 --- /dev/null +++ b/crates/core/src/live_socket/protocol/mod.rs @@ -0,0 +1,56 @@ +pub mod event; + +use super::{LiveChannel, LiveSocketError}; +use crate::dom::NodeRef; +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 { + pub async fn send_event( + &self, + event: PhxEvent, + value: Option, + sender: &NodeRef, + ) -> Result { + let val = value.map(JSON::deserialize).transpose()?; + self.send_event_json(event, val, sender).await + } + + pub async fn send_event_json( + &self, + event: PhxEvent, + value: Option, + sender: &NodeRef, + ) -> Result { + let event_attr = self + .document + .inner() + .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(), + ))?; + + 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.into_channel_message(); + let res = self.channel.call(user_event, payload, self.timeout).await; + + 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 7af3a19e..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"; @@ -416,9 +407,11 @@ 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(), @@ -521,6 +514,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 { @@ -532,6 +526,7 @@ impl LiveSocket { .ok_or(LiveSocketError::NoDocumentInJoinPayload)?; Ok(LiveChannel { + current_lock_id: Default::default(), channel, join_payload, socket: self.socket(), diff --git a/crates/core/src/live_socket/tests/mod.rs b/crates/core/src/live_socket/tests/mod.rs index 76b3bc88..4ffedc33 100644 --- a/crates/core/src/live_socket/tests/mod.rs +++ b/crates/core/src/live_socket/tests/mod.rs @@ -1,4 +1,10 @@ -use std::time::Duration; +use crate::dom::{ + Attribute, AttributeName, DocumentChangeHandler, Element, ElementName, NodeData, NodeRef, +}; +use crate::{attr, element}; +use std::{sync::Arc, time::Duration}; + +use crate::dom::ChangeType; use super::*; mod error; @@ -6,14 +12,12 @@ 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; +use tokio::sync::mpsc::*; + +/// serializes two documents so the formatting matches before diffing. macro_rules! assert_doc_eq { ($gold:expr, $test:expr) => {{ use crate::dom::Document; @@ -23,8 +27,41 @@ macro_rules! assert_doc_eq { }}; } +struct Inspector { + tx: UnboundedSender<(ChangeType, NodeData)>, + doc: crate::dom::ffi::Document, +} + +/// An extremely simple change handler that reports diffs in order +/// over an unbounded channel +impl DocumentChangeHandler for Inspector { + fn handle( + &self, + change_type: ChangeType, + _node_ref: Arc, + node_data: NodeData, + _parent: Option>, + ) { + let doc = self.doc.inner(); + + let _test = doc + .try_lock() + .expect("document was locked during change handler!"); + + self.tx + .send((change_type, node_data)) + .expect("Message Never Received."); + } +} + 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() @@ -50,8 +87,7 @@ async fn join_live_view() { .join_document() .expect("Failed to render join payload"); let rendered = format!("{}", join_doc); - let expected = r#" - + let expected = r#" Hello SwiftUI! @@ -65,6 +101,228 @@ async fn join_live_view() { .expect("Failed to join channel"); } +// channels sending events lock the dom by modifying attributes +// and class lists, this should notify the documents developer +// provided change listener +#[tokio::test] +async fn locking_dom_event_propogation() { + 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 doc = live_channel.document(); + let (tx, mut rx) = unbounded_channel(); + live_channel.set_event_handler(Box::new(Inspector { tx, doc })); + + let sender = live_channel + .document() + .inner() + .lock() + .expect("lock poison") + .get_by_id("button") + .expect("nothing by that name"); + + live_channel + .send_event_json(protocol::event::PhxEvent::PhxClick, None, &sender) + .await + .expect("click failed"); + + let mut buf = vec![]; + rx.recv_many(&mut buf, 4096).await; + assert_eq!( + vec![ + ( + ChangeType::Change, + element!( + "Button", + attr!("id" = "button"), + attr!("phx-click" = "inc_temperature"), + ) + ), + ( + ChangeType::Replace, + NodeData::leaf("Current temperature: 70°F") + ), + ( + ChangeType::Change, + element!( + "Button", + attr!("id" = "button"), + attr!("phx-click" = "inc_temperature"), + attr!("data-phx-ref-src" = "5"), + attr!("class" = "phx-click-loading") + ) + ) + ], + buf + ); +} + +#[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").expect("nothing by that name"); + + live_channel + .send_event_json(protocol::event::PhxEvent::PhxClick, None, &sender) + .await + .expect("click failed"); + + let expected = r#" + + + Current temperature: 71°F + + +"#; + + assert_doc_eq!(expected, live_channel.document().to_string()); +} + +#[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()); +} + +// Validate that shutdown has side effects. +#[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() @@ -96,3 +354,46 @@ async fn redirect() { .await .expect("Failed to join channel"); } + +#[test] +fn test_attr_macro() { + let attr1 = attr!("class"); + assert_eq!(attr1.name.name, "class"); + assert_eq!(attr1.name.namespace, None); + assert_eq!(attr1.value, None); + + let attr2 = attr!("class" = "container"); + assert_eq!(attr2.name.name, "class"); + assert_eq!(attr2.value, Some("container".to_string())); + + let attr3 = attr!("svg":"width"); + assert_eq!(attr3.name.namespace, Some("svg".to_string())); + assert_eq!(attr3.name.name, "width"); + assert_eq!(attr3.value, None); + + let attr4 = attr!("svg":"width" = "100"); + assert_eq!(attr4.name.namespace, Some("svg".to_string())); + assert_eq!(attr4.name.name, "width"); + assert_eq!(attr4.value, Some("100".to_string())); +} + +#[test] +fn test_element_macro() { + let input = element!("input", attr!("type" = "text"), attr!("id" = "username")); + + match input { + NodeData::NodeElement { element } => { + assert_eq!(element.name.name, "input"); + assert_eq!(element.attributes.len(), 2); + + let type_attr = &element.attributes[0]; + assert_eq!(type_attr.name.name, "type"); + assert_eq!(type_attr.value, Some("text".to_string())); + + let id_attr = &element.attributes[1]; + assert_eq!(id_attr.name.name, "id"); + assert_eq!(id_attr.value, Some("username".to_string())); + } + _ => panic!("Expected NodeElement"), + } +} 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}; diff --git a/crates/core/src/live_socket/tests/streaming.rs b/crates/core/src/live_socket/tests/streaming.rs index 405513c6..39528df2 100644 --- a/crates/core/src/live_socket/tests/streaming.rs +++ b/crates/core/src/live_socket/tests/streaming.rs @@ -1,9 +1,5 @@ -use std::sync::{Arc, Mutex}; - -use tokio::sync::oneshot::{self, *}; - use super::*; -use crate::dom::{ChangeType, DocumentChangeHandler, NodeData, NodeRef}; +use tokio::sync::mpsc::unbounded_channel; // As of this commit the server sends a // stream even every 10_000 ms @@ -11,36 +7,6 @@ use crate::dom::{ChangeType, DocumentChangeHandler, NodeData, NodeRef}; const MAX_TRIES: u64 = 120; const MS_DELAY: u64 = 100; -struct Inspector { - tx: std::sync::Mutex>>, - doc: crate::dom::ffi::Document, -} - -impl DocumentChangeHandler for Inspector { - fn handle( - &self, - _change_type: ChangeType, - _node_ref: Arc, - _node_data: NodeData, - _parent: Option>, - ) { - let tx = self - .tx - .lock() - .expect("lock poison") - .take() - .expect("Channel was None."); - - let doc = self.doc.inner(); - - let _test = doc - .try_lock() - .expect("document was locked during change handler!"); - - tx.send(()).expect("Message Never Received."); - } -} - // Tests that streaming connects, and succeeds at parsing at least one delta. #[tokio::test] async fn streaming_connect() -> Result<(), String> { @@ -61,11 +27,8 @@ async fn streaming_connect() -> Result<(), String> { .map_err(|e| format!("Failed to join the liveview channel {e}"))?; let doc = live_channel.document(); - let (tx, mut rx) = oneshot::channel(); - live_channel.set_event_handler(Box::new(Inspector { - tx: Mutex::new(Some(tx)), - doc, - })); + let (tx, mut rx) = unbounded_channel(); + live_channel.set_event_handler(Box::new(Inspector { tx, doc })); let chan_clone = live_channel.channel().clone(); tokio::spawn(async move { @@ -85,7 +48,7 @@ async fn streaming_connect() -> Result<(), String> { return Ok(()); } - Err(oneshot::error::TryRecvError::Empty) => { + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => { tokio::time::sleep(Duration::from_millis(MS_DELAY)).await; } Err(_) => { 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 - + """