diff --git a/extensions/pagetop-htmx/src/hx.rs b/extensions/pagetop-htmx/src/hx.rs index fb4ae54b..2f1edc34 100644 --- a/extensions/pagetop-htmx/src/hx.rs +++ b/extensions/pagetop-htmx/src/hx.rs @@ -381,8 +381,7 @@ pub fn on_htmx(event: &str) -> String { /// /// Están en minúsculas porque así las normaliza el módulo `http`. Se pueden usar con /// [`HttpRequest::headers()`](pagetop::web::HttpRequest::headers) para leer sus valores -/// directamente, aunque lo habitual es usar el trait -/// [`HtmxRequestExt`](crate::request::HtmxRequestExt). +/// directamente, aunque lo habitual es usar el trait [`HtmxRequestExt`](crate::HtmxRequestExt). /// /// ```rust,no_run /// use pagetop::prelude::*; @@ -418,8 +417,7 @@ pub mod request { /// Cabeceras de respuesta HTTP para HTMX. /// /// Se pueden usar con [`HeaderMap`](pagetop::web::http::HeaderMap) para construir respuestas -/// manualmente, aunque lo habitual es usar el constructor -/// [`HtmxResponse`](crate::response::HtmxResponse). +/// manualmente, aunque lo habitual es usar el constructor [`HtmxResponse`](crate::HtmxResponse). /// /// ```rust,no_run /// use pagetop::prelude::*; @@ -433,38 +431,38 @@ pub mod request { /// ``` pub mod response { /// Redirige mediante AJAX a la URL o configuración JSON indicada. Ver - /// [`HtmxResponse::location()`](crate::response::HtmxResponse::location) y - /// [`HtmxResponse::location_json()`](crate::response::HtmxResponse::location_json). + /// [`HtmxResponse::location()`](crate::HtmxResponse::location) y + /// [`HtmxResponse::location_json()`](crate::HtmxResponse::location_json). pub const LOCATION: &str = "HX-Location"; /// Empuja la URL indicada al historial del navegador. Ver - /// [`HtmxResponse::push_url()`](crate::response::HtmxResponse::push_url). + /// [`HtmxResponse::push_url()`](crate::HtmxResponse::push_url). pub const PUSH_URL: &str = "HX-Push-Url"; /// Provoca una redirección completa del navegador. Ver - /// [`HtmxResponse::redirect()`](crate::response::HtmxResponse::redirect). + /// [`HtmxResponse::redirect()`](crate::HtmxResponse::redirect). pub const REDIRECT: &str = "HX-Redirect"; /// Provoca una recarga completa de la página. Ver - /// [`HtmxResponse::refresh()`](crate::response::HtmxResponse::refresh). + /// [`HtmxResponse::refresh()`](crate::HtmxResponse::refresh). pub const REFRESH: &str = "HX-Refresh"; /// Reemplaza la URL actual en el historial. Ver - /// [`HtmxResponse::replace_url()`](crate::response::HtmxResponse::replace_url). + /// [`HtmxResponse::replace_url()`](crate::HtmxResponse::replace_url). pub const REPLACE_URL: &str = "HX-Replace-Url"; /// Anula el `hx-swap` del elemento. Ver - /// [`HtmxResponse::reswap()`](crate::response::HtmxResponse::reswap). + /// [`HtmxResponse::reswap()`](crate::HtmxResponse::reswap). pub const RESWAP: &str = "HX-Reswap"; /// Anula el `hx-target` del elemento. Ver - /// [`HtmxResponse::retarget()`](crate::response::HtmxResponse::retarget). + /// [`HtmxResponse::retarget()`](crate::HtmxResponse::retarget). pub const RETARGET: &str = "HX-Retarget"; /// Anula el `hx-select` del elemento. Ver - /// [`HtmxResponse::reselect()`](crate::response::HtmxResponse::reselect). + /// [`HtmxResponse::reselect()`](crate::HtmxResponse::reselect). pub const RESELECT: &str = "HX-Reselect"; /// Dispara eventos JavaScript al completar la respuesta. Ver - /// [`HtmxResponse::trigger()`](crate::response::HtmxResponse::trigger). + /// [`HtmxResponse::trigger()`](crate::HtmxResponse::trigger). pub const TRIGGER: &str = "HX-Trigger"; /// Dispara eventos tras la fase *settle*. Ver - /// [`HtmxResponse::trigger_after_settle()`](crate::response::HtmxResponse::trigger_after_settle). + /// [`HtmxResponse::trigger_after_settle()`](crate::HtmxResponse::trigger_after_settle). pub const TRIGGER_AFTER_SETTLE: &str = "HX-Trigger-After-Settle"; /// Dispara eventos tras el *swap*. Ver - /// [`HtmxResponse::trigger_after_swap()`](crate::response::HtmxResponse::trigger_after_swap). + /// [`HtmxResponse::trigger_after_swap()`](crate::HtmxResponse::trigger_after_swap). pub const TRIGGER_AFTER_SWAP: &str = "HX-Trigger-After-Swap"; } diff --git a/extensions/pagetop-htmx/src/lib.rs b/extensions/pagetop-htmx/src/lib.rs index f127c227..30c96dba 100644 --- a/extensions/pagetop-htmx/src/lib.rs +++ b/extensions/pagetop-htmx/src/lib.rs @@ -93,13 +93,18 @@ include_locales!(LOCALES_HTMX); pub mod hx; pub mod hx_table; -pub mod request; -pub mod response; + +mod request; +pub use request::HtmxRequestExt; + +mod response; +pub use response::HtmxResponse; /// Prelude de `pagetop-htmx`. pub mod prelude { pub use crate::hx; pub use crate::hx_table; + pub use crate::request::HtmxRequestExt; pub use crate::response::HtmxResponse; } diff --git a/extensions/pagetop-htmx/src/request.rs b/extensions/pagetop-htmx/src/request.rs index cb571e71..ff14e2f0 100644 --- a/extensions/pagetop-htmx/src/request.rs +++ b/extensions/pagetop-htmx/src/request.rs @@ -4,7 +4,8 @@ use pagetop::prelude::*; // **< HtmxRequestExt >***************************************************************************** -/// Extiende [`HttpRequest`] con métodos para detectar y leer peticiones HTMX. +/// Extiende [`HttpRequest`](pagetop::web::HttpRequest) con métodos para detectar y leer peticiones +/// HTMX. /// /// HTMX añade cabeceras especiales a cada petición AJAX. Este trait permite acceder a ellas de /// forma expresiva, sin manipular [`pagetop::web::http::HeaderMap`] directamente. diff --git a/extensions/pagetop-htmx/src/response.rs b/extensions/pagetop-htmx/src/response.rs index 0712ae20..b9221e92 100644 --- a/extensions/pagetop-htmx/src/response.rs +++ b/extensions/pagetop-htmx/src/response.rs @@ -1,4 +1,4 @@ -//! Implementación de [`HtmxResponse`] e [`IntoResponse`] para HTMX. +//! Implementación de [`HtmxResponse`] e [`IntoResponse`](pagetop::web::IntoResponse) para HTMX. use pagetop::prelude::*; @@ -10,7 +10,8 @@ use pagetop::prelude::*; /// parciales acompañados de cabeceras especiales que instruyen al cliente sobre qué hacer con la /// respuesta: actualizar la URL del historial, disparar eventos JavaScript, redirigir, etc. /// -/// Implementa [`IntoResponse`], por lo que puede devolverse directamente desde cualquier handler. +/// Implementa [`IntoResponse`](pagetop::web::IntoResponse), por lo que puede devolverse +/// directamente desde cualquier handler. /// /// # Ejemplo /// diff --git a/extensions/pagetop-htmx/tests/request.rs b/extensions/pagetop-htmx/tests/request.rs index 90995ec0..63bd842c 100644 --- a/extensions/pagetop-htmx/tests/request.rs +++ b/extensions/pagetop-htmx/tests/request.rs @@ -1,5 +1,5 @@ use pagetop::prelude::*; -use pagetop_htmx::prelude::*; +use pagetop_htmx::HtmxRequestExt; struct TestApp; diff --git a/src/core/component/children.rs b/src/core/component/children.rs index 31d299a8..cadcb05c 100644 --- a/src/core/component/children.rs +++ b/src/core/component/children.rs @@ -2,6 +2,8 @@ use crate::core::component::{Component, Context}; use crate::html::{Markup, html}; use crate::{AutoDefault, UniqueId, builder_fn}; +use parking_lot::RwLock; + use std::fmt; use std::sync::Arc; use std::vec::IntoIter; @@ -9,19 +11,14 @@ use std::vec::IntoIter; // **< Child >************************************************************************************** /// Representa un componente hijo encapsulado para su uso en una lista [`Children`]. -/// -/// Envuelve el componente en `Arc`, compartido y de sólo lectura. Clonar un `Child` -/// sólo incrementa el contador de referencias. Para renderizar obtiene una copia propia con -/// [`ComponentClone::clone_box()`](crate::core::component::ComponentClone::clone_box), de modo que -/// el componente original nunca se modifica. #[derive(AutoDefault, Clone)] -pub struct Child(Option>); +pub struct Child(Option>>>); impl fmt::Debug for Child { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match &self.0 { None => write!(f, "Child(None)"), - Some(c) => write!(f, "Child({})", c.name()), + Some(c) => write!(f, "Child({})", c.read().name()), } } } @@ -29,14 +26,7 @@ impl fmt::Debug for Child { impl Child { /// Crea un nuevo `Child` a partir de un componente. pub fn with(component: impl Component) -> Self { - Child(Some(Arc::new(component))) - } - - // Envuelve un `Arc` ya construido, sin clonar el componente. La usa `core::theme::regions` para - // registrar los prototipos de `InRegion` sin clonar su estado hasta que `render()` obtenga la - // copia propia que necesita mutar. - pub(crate) fn from_arc(component: Arc) -> Self { - Child(Some(component)) + Child(Some(Arc::new(RwLock::new(Box::new(component))))) } // **< Child BUILDER >************************************************************************** @@ -46,7 +36,7 @@ impl Child { /// Si se proporciona `Some(component)`, se encapsula como [`Child`]; y si es `None`, se limpia. #[builder_fn] pub fn with_component(mut self, component: Option) -> Self { - self.0 = component.map(|c| Arc::new(c) as Arc); + self.0 = component.map(|c| Arc::new(RwLock::new(Box::new(c) as Box))); self } @@ -55,7 +45,7 @@ impl Child { /// Devuelve el identificador del componente, si existe y está definido. #[inline] pub fn id(&self) -> Option { - self.0.as_ref().and_then(|c| c.id()) + self.0.as_ref().and_then(|c| c.read().id()) } // **< Child RENDER >*************************************************************************** @@ -65,7 +55,7 @@ impl Child { match &self.0 { None => html! {}, Some(m) => { - let mut component = m.clone_box(); + let mut component = m.read().clone_box(); component.render(cx).await } } @@ -76,39 +66,39 @@ impl Child { // Devuelve el [`UniqueId`] del tipo del componente, si el Child no está vacío. #[inline] fn type_id(&self) -> Option { - self.0.as_ref().map(|c| c.type_id()) + self.0.as_ref().map(|c| c.read().type_id()) } } -impl From> for Child { +impl From> for Child { /// Convierte un [`Embed`] en un [`Child`], consumiendo el componente tipado. /// - /// Útil cuando se tiene un [`Embed`] para añadir a una lista [`Children`]: + /// Útil cuando se tiene un [`Embed`] y se necesita añadirlo a una lista [`Children`]: /// - /// ```rust,no_run - /// # use pagetop::prelude::*; - /// let my_embed = Embed::with(Html::with(|_| html! { "Text" })); - /// let children = Children::new().with_child(Child::from(my_embed)); - /// - /// // De forma equivalente se puede usar la conversión implícita hacia `Child`: - /// let my_embed = Embed::with(Html::with(|_| html! { "Text" })); - /// let child: Child = my_embed.into(); - /// let children = children.with_child(child); + /// ```rust,ignore + /// children.with_child(Child::from(my_embed)); + /// // o equivalentemente: + /// children.with_child(my_embed.into()); /// ``` fn from(embed: Embed) -> Self { - Child(embed.0.map(|arc| arc as Arc)) + match embed.0 { + None => Child(None), + Some(arc) => Child(Some(Arc::new(RwLock::new(match Arc::try_unwrap(arc) { + Ok(c) => Box::new(c) as Box, + Err(arc) => arc.clone_box(), + })))), + } } } -impl From for Child { - /// Convierte cualquier componente en un [`Child`], equivalente a [`Child::with()`]. +impl From for Child { #[inline] fn from(component: T) -> Self { Child::with(component) } } -impl From for ChildOp { +impl From for ChildOp { /// Convierte un componente en [`ChildOp::Add`], permitiendo pasar componentes directamente a /// métodos como [`Children::with_child`] sin envolverlos explícitamente. #[inline] @@ -280,13 +270,12 @@ pub enum ChildOp { /// Gracias a esto, [`with_child`](Self::with_child) acepta un componente directamente o cualquier /// variante de [`ChildOp`]: /// -/// ```rust,no_run -/// # use pagetop::prelude::*; +/// ```rust,ignore /// // Añadir al final de la lista (implícito): -/// let children = Children::new().with_child(Html::new()); +/// children.with_child(MiComponente::new()); /// /// // Operación explícita: -/// let children = children.with_child(ChildOp::Prepend(Html::new().into())); +/// children.with_child(ChildOp::Prepend(MiComponente::new().into())); /// ``` #[derive(AutoDefault, Clone, Debug)] pub struct Children(Vec); @@ -381,12 +370,8 @@ impl Children { // **< Children HELPERS >*********************************************************************** // Añade más de un componente hijo al final de la lista (en el orden recibido). - // - // Usa `Vec::extend()`, que reserva la capacidad necesaria de una vez a partir del `size_hint()` - // del iterador, en vez de una reasignación incremental por cada `push()`. También lo usa - // `core::theme::regions` para fusionar las fuentes de una región. #[inline] - pub(crate) fn add_many(&mut self, iter: I) -> &mut Self + fn add_many(&mut self, iter: I) -> &mut Self where I: IntoIterator, { @@ -473,9 +458,8 @@ impl IntoIterator for Children { /// /// # Ejemplo /// - /// ```rust,no_run - /// # use pagetop::prelude::*; - /// let children = Children::new().with_child(Html::new()).with_child(Html::new()); + /// ```rust,ignore + /// let children = Children::new().with_child(child1).with_child(child2); /// for child in children { /// println!("{:?}", child.id()); /// } @@ -493,9 +477,8 @@ impl<'a> IntoIterator for &'a Children { /// /// # Ejemplo /// - /// ```rust,no_run - /// # use pagetop::prelude::*; - /// let children = Children::new().with_child(Html::new()).with_child(Html::new()); + /// ```rust,ignore + /// let children = Children::new().with_child(child1).with_child(child2); /// for child in &children { /// println!("{:?}", child.id()); /// } @@ -513,12 +496,10 @@ impl<'a> IntoIterator for &'a mut Children { /// /// # Ejemplo /// - /// ```rust,no_run - /// # use pagetop::prelude::*; - /// async fn render_all(mut children: Children, context: &mut Context) { - /// for child in &mut children { - /// child.render(context).await; - /// } + /// ```rust,ignore + /// let mut children = Children::new().with_child(child1).with_child(child2); + /// for child in &mut children { + /// child.render(&mut context).await; /// } /// ``` fn into_iter(self) -> Self::IntoIter { diff --git a/src/core/theme/regions.rs b/src/core/theme/regions.rs index 9543b31b..f6c00aed 100644 --- a/src/core/theme/regions.rs +++ b/src/core/theme/regions.rs @@ -7,53 +7,40 @@ use parking_lot::RwLock; use std::collections::HashMap; use std::sync::{Arc, LazyLock}; -// Lista de prototipos de componentes por nombre de región. +// Permite almacenar un componente como prototipo en regiones globales. // -// Utiliza Vec en lugar de HashMap. El número de regiones registradas por tema o aplicación es casi -// siempre de un dígito, así que una búsqueda lineal por igualdad de `&str` evita el coste de -// hashear la clave. Además, el trabajo para recorrer regiones vacías es mínimo. -// -// La clave es `&'static str` (lo que ya devuelve `RegionName::name()`) en lugar de `String`. No -// hace falta reservar en el heap una copia de un dato que ya vive de forma estática. -#[derive(AutoDefault)] -struct RegionComponents(Vec<(&'static str, Vec>)>); +// Se implementa automáticamente para todo tipo que implemente [`Component`] y [`Clone`]. En cada +// llamada a [`as_child`](Self::as_child) produce un clon fresco del estado original, de modo que +// cada página renderiza el componente desde su estado inicial sin acumular mutaciones de peticiones +// anteriores. +trait ComponentGlobal: Send + Sync { + // Devuelve un nuevo [`Child`] con una copia independiente del componente original. + fn as_child(&self) -> Child; +} -impl RegionComponents { - // Devuelve los prototipos registrados para la región indicada, si hay alguno. - fn get(&self, region_name: &str) -> Option<&Vec>> { - self.0 - .iter() - .find(|(name, _)| *name == region_name) - .map(|(_, protos)| protos) - } - - // Añade un prototipo a la región indicada, creando la entrada si es la primera. - // - // Se comparte como `Arc`. El prototipo no se clona aquí ni al ensamblar la - // región (sólo se clona el `Arc`, barato). El único clonado real del componente ocurre en - // `Child::render()`, cuando cada petición necesita su propia copia mutable para pasar por - // `setup()` desde un estado inicial limpio. - fn push(&mut self, region_name: &'static str, proto: Arc) { - match self.0.iter_mut().find(|(name, _)| *name == region_name) { - Some((_, protos)) => protos.push(proto), - None => self.0.push((region_name, vec![proto])), - } +impl ComponentGlobal for T { + #[inline] + fn as_child(&self) -> Child { + Child::with(self.clone()) } } +// Mapa de nombre de región a lista de prototipos de componentes. +type RegionComponents = HashMap>>; + // Regiones globales con prototipos asociados a un tema específico. static THEME_REGIONS: LazyLock>> = LazyLock::new(|| RwLock::new(HashMap::new())); // Regiones globales con prototipos comunes a todos los temas. static COMMON_REGIONS: LazyLock> = - LazyLock::new(|| RwLock::new(RegionComponents::default())); + LazyLock::new(|| RwLock::new(HashMap::new())); // ************************************************************************************************* // Contenedor interno de componentes agrupados por región. #[derive(AutoDefault)] -pub(crate) struct ChildrenInRegions(HashMap<&'static str, Children>); +pub(crate) struct ChildrenInRegions(HashMap); impl ChildrenInRegions { pub fn with(region: RegionRef, child: Child) -> Self { @@ -68,7 +55,7 @@ impl ChildrenInRegions { region.alter_child(child); } else { let children = Children::new().with_child(child); - self.0.insert(region_name, children); + self.0.insert(region_name.to_owned(), children); } self } @@ -77,39 +64,38 @@ impl ChildrenInRegions { /// /// Se recogen desde tres fuentes disponibles, en el siguiente orden: /// - /// 1. Prototipos globales comunes, disponibles en cualquier tema. Se comparten como `Arc` (sin - /// clonar el componente); `Child::render()` obtiene su propia copia mutable más adelante, + /// 1. Prototipos globales comunes, disponibles en cualquier tema. Se clonan en cada petición /// para que `setup()` parta siempre de un estado inicial limpio. /// 2. Componentes propios de la página, registrados para esta petición concreta. Se mueven en /// lugar de clonarse, ya que son de un único uso. - /// 3. Prototipos del tema activo, exclusivos del tema en curso. Se comparten igual que los - /// comunes. + /// 3. Prototipos del tema activo, exclusivos del tema en curso. También se clonan para asegurar + /// que llegan a `setup()` con el mismo estado inicial. pub fn assemble_region(&mut self, theme: ThemeRef, region: RegionRef) -> Children { + let region_name = region.name(); + let common = COMMON_REGIONS.read(); + let themed = THEME_REGIONS.read(); + let mut result = Children::new(); - let region_name = region.name(); - // 1. Prototipos globales comunes. - if let Some(global_protos) = COMMON_REGIONS.read().get(region_name) { - result.add_many( - global_protos - .iter() - .map(|proto| Child::from_arc(Arc::clone(proto))), - ); + if let Some(protos) = common.get(region_name) { + for proto in protos { + result.add(proto.as_child()); + } } // 2. Componentes propios de la página: se mueven, no se clonan. if let Some(page_children) = self.0.remove(region_name) { - result.add_many(page_children); + for child in page_children { + result.add(child); + } } // 3. Prototipos del tema activo. - if let Some(theme_region) = THEME_REGIONS.read().get(&theme.type_id()) - && let Some(theme_protos) = theme_region.get(region_name) - { - result.add_many( - theme_protos - .iter() - .map(|proto| Child::from_arc(Arc::clone(proto))), - ); + if let Some(theme_map) = themed.get(&theme.type_id()) { + if let Some(protos) = theme_map.get(region_name) { + for proto in protos { + result.add(proto.as_child()); + } + } } result @@ -182,8 +168,8 @@ impl InRegion { /// html! { "Aviso legal" } /// })); /// ``` - pub fn add(&self, component: impl Component) -> &Self { - let proto: Arc = Arc::new(component); + pub fn add(&self, component: impl Component + Clone + 'static) -> &Self { + let proto: Arc = Arc::new(component); match self { InRegion::Content => Self::add_to_common(&CoreRegion::Content, proto), InRegion::Global(region) => Self::add_to_common(*region, proto), @@ -192,14 +178,20 @@ impl InRegion { .write() .entry(theme.type_id()) .or_default() - .push((*region).name(), proto); + .entry((*region).name().to_owned()) + .or_default() + .push(proto); } } self } #[inline] - fn add_to_common(region: RegionRef, proto: Arc) { - COMMON_REGIONS.write().push(region.name(), proto); + fn add_to_common(region: RegionRef, proto: Arc) { + COMMON_REGIONS + .write() + .entry(region.name().to_owned()) + .or_default() + .push(proto); } } diff --git a/src/web.rs b/src/web.rs index 498f9570..4dd79b77 100644 --- a/src/web.rs +++ b/src/web.rs @@ -347,7 +347,6 @@ pub mod test { pub struct TestRequest { method: http::Method, uri: String, - headers: http::HeaderMap, extensions: http::Extensions, } @@ -357,7 +356,6 @@ pub mod test { Self { method: http::Method::GET, uri: "/".to_owned(), - headers: http::HeaderMap::new(), extensions: http::Extensions::new(), } } @@ -367,7 +365,6 @@ pub mod test { Self { method: http::Method::POST, uri: "/".to_owned(), - headers: http::HeaderMap::new(), extensions: http::Extensions::new(), } } @@ -378,20 +375,6 @@ pub mod test { self } - /// Añade una cabecera a la petición. - /// - /// Si `name` o `value` no son válidos como cabecera HTTP, se descarta en silencio en lugar - /// de entrar en pánico. - pub fn header(mut self, name: impl AsRef, value: impl AsRef) -> Self { - if let (Ok(n), Ok(v)) = ( - http::HeaderName::from_bytes(name.as_ref().as_bytes()), - http::HeaderValue::from_str(value.as_ref()), - ) { - self.headers.insert(n, v); - } - self - } - /// Inserta un valor en las extensiones de la petición. /// /// Útil para simular lo que un middleware haría en producción antes de que el handler @@ -408,7 +391,6 @@ pub mod test { .uri(self.uri) .body(Body::empty()) .unwrap(); - *req.headers_mut() = self.headers; *req.extensions_mut() = self.extensions; req } @@ -419,7 +401,7 @@ pub mod test { let uri = self.uri.parse().unwrap(); super::HttpRequest { uri, - headers: self.headers, + headers: axum::http::HeaderMap::new(), extensions: std::sync::Arc::new(self.extensions), } }