diff --git a/extensions/pagetop-htmx/src/hx.rs b/extensions/pagetop-htmx/src/hx.rs index 2f1edc34..fb4ae54b 100644 --- a/extensions/pagetop-htmx/src/hx.rs +++ b/extensions/pagetop-htmx/src/hx.rs @@ -381,7 +381,8 @@ 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::HtmxRequestExt). +/// directamente, aunque lo habitual es usar el trait +/// [`HtmxRequestExt`](crate::request::HtmxRequestExt). /// /// ```rust,no_run /// use pagetop::prelude::*; @@ -417,7 +418,8 @@ 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::HtmxResponse). +/// manualmente, aunque lo habitual es usar el constructor +/// [`HtmxResponse`](crate::response::HtmxResponse). /// /// ```rust,no_run /// use pagetop::prelude::*; @@ -431,38 +433,38 @@ pub mod request { /// ``` pub mod response { /// Redirige mediante AJAX a la URL o configuración JSON indicada. Ver - /// [`HtmxResponse::location()`](crate::HtmxResponse::location) y - /// [`HtmxResponse::location_json()`](crate::HtmxResponse::location_json). + /// [`HtmxResponse::location()`](crate::response::HtmxResponse::location) y + /// [`HtmxResponse::location_json()`](crate::response::HtmxResponse::location_json). pub const LOCATION: &str = "HX-Location"; /// Empuja la URL indicada al historial del navegador. Ver - /// [`HtmxResponse::push_url()`](crate::HtmxResponse::push_url). + /// [`HtmxResponse::push_url()`](crate::response::HtmxResponse::push_url). pub const PUSH_URL: &str = "HX-Push-Url"; /// Provoca una redirección completa del navegador. Ver - /// [`HtmxResponse::redirect()`](crate::HtmxResponse::redirect). + /// [`HtmxResponse::redirect()`](crate::response::HtmxResponse::redirect). pub const REDIRECT: &str = "HX-Redirect"; /// Provoca una recarga completa de la página. Ver - /// [`HtmxResponse::refresh()`](crate::HtmxResponse::refresh). + /// [`HtmxResponse::refresh()`](crate::response::HtmxResponse::refresh). pub const REFRESH: &str = "HX-Refresh"; /// Reemplaza la URL actual en el historial. Ver - /// [`HtmxResponse::replace_url()`](crate::HtmxResponse::replace_url). + /// [`HtmxResponse::replace_url()`](crate::response::HtmxResponse::replace_url). pub const REPLACE_URL: &str = "HX-Replace-Url"; /// Anula el `hx-swap` del elemento. Ver - /// [`HtmxResponse::reswap()`](crate::HtmxResponse::reswap). + /// [`HtmxResponse::reswap()`](crate::response::HtmxResponse::reswap). pub const RESWAP: &str = "HX-Reswap"; /// Anula el `hx-target` del elemento. Ver - /// [`HtmxResponse::retarget()`](crate::HtmxResponse::retarget). + /// [`HtmxResponse::retarget()`](crate::response::HtmxResponse::retarget). pub const RETARGET: &str = "HX-Retarget"; /// Anula el `hx-select` del elemento. Ver - /// [`HtmxResponse::reselect()`](crate::HtmxResponse::reselect). + /// [`HtmxResponse::reselect()`](crate::response::HtmxResponse::reselect). pub const RESELECT: &str = "HX-Reselect"; /// Dispara eventos JavaScript al completar la respuesta. Ver - /// [`HtmxResponse::trigger()`](crate::HtmxResponse::trigger). + /// [`HtmxResponse::trigger()`](crate::response::HtmxResponse::trigger). pub const TRIGGER: &str = "HX-Trigger"; /// Dispara eventos tras la fase *settle*. Ver - /// [`HtmxResponse::trigger_after_settle()`](crate::HtmxResponse::trigger_after_settle). + /// [`HtmxResponse::trigger_after_settle()`](crate::response::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::HtmxResponse::trigger_after_swap). + /// [`HtmxResponse::trigger_after_swap()`](crate::response::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 30c96dba..f127c227 100644 --- a/extensions/pagetop-htmx/src/lib.rs +++ b/extensions/pagetop-htmx/src/lib.rs @@ -93,18 +93,13 @@ include_locales!(LOCALES_HTMX); pub mod hx; pub mod hx_table; - -mod request; -pub use request::HtmxRequestExt; - -mod response; -pub use response::HtmxResponse; +pub mod request; +pub mod response; /// 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 ff14e2f0..cb571e71 100644 --- a/extensions/pagetop-htmx/src/request.rs +++ b/extensions/pagetop-htmx/src/request.rs @@ -4,8 +4,7 @@ use pagetop::prelude::*; // **< HtmxRequestExt >***************************************************************************** -/// Extiende [`HttpRequest`](pagetop::web::HttpRequest) con métodos para detectar y leer peticiones -/// HTMX. +/// Extiende [`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 b9221e92..0712ae20 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`](pagetop::web::IntoResponse) para HTMX. +//! Implementación de [`HtmxResponse`] e [`IntoResponse`] para HTMX. use pagetop::prelude::*; @@ -10,8 +10,7 @@ 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`](pagetop::web::IntoResponse), por lo que puede devolverse -/// directamente desde cualquier handler. +/// Implementa [`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 63bd842c..90995ec0 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::HtmxRequestExt; +use pagetop_htmx::prelude::*; struct TestApp; diff --git a/src/core/component/children.rs b/src/core/component/children.rs index cadcb05c..31d299a8 100644 --- a/src/core/component/children.rs +++ b/src/core/component/children.rs @@ -2,8 +2,6 @@ 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; @@ -11,14 +9,19 @@ 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.read().name()), + Some(c) => write!(f, "Child({})", c.name()), } } } @@ -26,7 +29,14 @@ 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(RwLock::new(Box::new(component))))) + 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 BUILDER >************************************************************************** @@ -36,7 +46,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(RwLock::new(Box::new(c) as Box))); + self.0 = component.map(|c| Arc::new(c) as Arc); self } @@ -45,7 +55,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.read().id()) + self.0.as_ref().and_then(|c| c.id()) } // **< Child RENDER >*************************************************************************** @@ -55,7 +65,7 @@ impl Child { match &self.0 { None => html! {}, Some(m) => { - let mut component = m.read().clone_box(); + let mut component = m.clone_box(); component.render(cx).await } } @@ -66,39 +76,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.read().type_id()) + self.0.as_ref().map(|c| c.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`] y se necesita añadirlo a una lista [`Children`]: + /// Útil cuando se tiene un [`Embed`] para añadir a una lista [`Children`]: /// - /// ```rust,ignore - /// children.with_child(Child::from(my_embed)); - /// // o equivalentemente: - /// children.with_child(my_embed.into()); + /// ```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); /// ``` fn from(embed: Embed) -> Self { - 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(), - })))), - } + Child(embed.0.map(|arc| arc as Arc)) } } -impl From for Child { +impl From for Child { + /// Convierte cualquier componente en un [`Child`], equivalente a [`Child::with()`]. #[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] @@ -270,12 +280,13 @@ pub enum ChildOp { /// Gracias a esto, [`with_child`](Self::with_child) acepta un componente directamente o cualquier /// variante de [`ChildOp`]: /// -/// ```rust,ignore +/// ```rust,no_run +/// # use pagetop::prelude::*; /// // Añadir al final de la lista (implícito): -/// children.with_child(MiComponente::new()); +/// let children = Children::new().with_child(Html::new()); /// /// // Operación explícita: -/// children.with_child(ChildOp::Prepend(MiComponente::new().into())); +/// let children = children.with_child(ChildOp::Prepend(Html::new().into())); /// ``` #[derive(AutoDefault, Clone, Debug)] pub struct Children(Vec); @@ -370,8 +381,12 @@ 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] - fn add_many(&mut self, iter: I) -> &mut Self + pub(crate) fn add_many(&mut self, iter: I) -> &mut Self where I: IntoIterator, { @@ -458,8 +473,9 @@ impl IntoIterator for Children { /// /// # Ejemplo /// - /// ```rust,ignore - /// let children = Children::new().with_child(child1).with_child(child2); + /// ```rust,no_run + /// # use pagetop::prelude::*; + /// let children = Children::new().with_child(Html::new()).with_child(Html::new()); /// for child in children { /// println!("{:?}", child.id()); /// } @@ -477,8 +493,9 @@ impl<'a> IntoIterator for &'a Children { /// /// # Ejemplo /// - /// ```rust,ignore - /// let children = Children::new().with_child(child1).with_child(child2); + /// ```rust,no_run + /// # use pagetop::prelude::*; + /// let children = Children::new().with_child(Html::new()).with_child(Html::new()); /// for child in &children { /// println!("{:?}", child.id()); /// } @@ -496,10 +513,12 @@ impl<'a> IntoIterator for &'a mut Children { /// /// # Ejemplo /// - /// ```rust,ignore - /// let mut children = Children::new().with_child(child1).with_child(child2); - /// for child in &mut children { - /// child.render(&mut context).await; + /// ```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; + /// } /// } /// ``` fn into_iter(self) -> Self::IntoIter { diff --git a/src/core/theme/regions.rs b/src/core/theme/regions.rs index f6c00aed..9543b31b 100644 --- a/src/core/theme/regions.rs +++ b/src/core/theme/regions.rs @@ -7,40 +7,53 @@ use parking_lot::RwLock; use std::collections::HashMap; use std::sync::{Arc, LazyLock}; -// Permite almacenar un componente como prototipo en regiones globales. +// Lista de prototipos de componentes por nombre de región. // -// 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; -} +// 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>)>); -impl ComponentGlobal for T { - #[inline] - fn as_child(&self) -> Child { - Child::with(self.clone()) +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])), + } } } -// 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(HashMap::new())); + LazyLock::new(|| RwLock::new(RegionComponents::default())); // ************************************************************************************************* // Contenedor interno de componentes agrupados por región. #[derive(AutoDefault)] -pub(crate) struct ChildrenInRegions(HashMap); +pub(crate) struct ChildrenInRegions(HashMap<&'static str, Children>); impl ChildrenInRegions { pub fn with(region: RegionRef, child: Child) -> Self { @@ -55,7 +68,7 @@ impl ChildrenInRegions { region.alter_child(child); } else { let children = Children::new().with_child(child); - self.0.insert(region_name.to_owned(), children); + self.0.insert(region_name, children); } self } @@ -64,38 +77,39 @@ impl ChildrenInRegions { /// /// Se recogen desde tres fuentes disponibles, en el siguiente orden: /// - /// 1. Prototipos globales comunes, disponibles en cualquier tema. Se clonan en cada petición + /// 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, /// 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. También se clonan para asegurar - /// que llegan a `setup()` con el mismo estado inicial. + /// 3. Prototipos del tema activo, exclusivos del tema en curso. Se comparten igual que los + /// comunes. 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(protos) = common.get(region_name) { - for proto in protos { - result.add(proto.as_child()); - } + 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))), + ); } // 2. Componentes propios de la página: se mueven, no se clonan. if let Some(page_children) = self.0.remove(region_name) { - for child in page_children { - result.add(child); - } + result.add_many(page_children); } // 3. Prototipos del tema activo. - 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()); - } - } + 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))), + ); } result @@ -168,8 +182,8 @@ impl InRegion { /// html! { "Aviso legal" } /// })); /// ``` - pub fn add(&self, component: impl Component + Clone + 'static) -> &Self { - let proto: Arc = Arc::new(component); + pub fn add(&self, component: impl Component) -> &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), @@ -178,20 +192,14 @@ impl InRegion { .write() .entry(theme.type_id()) .or_default() - .entry((*region).name().to_owned()) - .or_default() - .push(proto); + .push((*region).name(), proto); } } self } #[inline] - fn add_to_common(region: RegionRef, proto: Arc) { - COMMON_REGIONS - .write() - .entry(region.name().to_owned()) - .or_default() - .push(proto); + fn add_to_common(region: RegionRef, proto: Arc) { + COMMON_REGIONS.write().push(region.name(), proto); } } diff --git a/src/web.rs b/src/web.rs index 4dd79b77..498f9570 100644 --- a/src/web.rs +++ b/src/web.rs @@ -347,6 +347,7 @@ pub mod test { pub struct TestRequest { method: http::Method, uri: String, + headers: http::HeaderMap, extensions: http::Extensions, } @@ -356,6 +357,7 @@ pub mod test { Self { method: http::Method::GET, uri: "/".to_owned(), + headers: http::HeaderMap::new(), extensions: http::Extensions::new(), } } @@ -365,6 +367,7 @@ pub mod test { Self { method: http::Method::POST, uri: "/".to_owned(), + headers: http::HeaderMap::new(), extensions: http::Extensions::new(), } } @@ -375,6 +378,20 @@ 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 @@ -391,6 +408,7 @@ pub mod test { .uri(self.uri) .body(Body::empty()) .unwrap(); + *req.headers_mut() = self.headers; *req.extensions_mut() = self.extensions; req } @@ -401,7 +419,7 @@ pub mod test { let uri = self.uri.parse().unwrap(); super::HttpRequest { uri, - headers: axum::http::HeaderMap::new(), + headers: self.headers, extensions: std::sync::Arc::new(self.extensions), } }