Compare commits

..

6 commits

Author SHA1 Message Date
74dbcde5c5 ♻️ (core): Cambia HashMap x Vec en RegionComponents 2026-07-26 13:41:32 +02:00
7e6a9f8557 ♻️ (core): Clave &'static str en RegionComponents 2026-07-26 12:50:33 +02:00
845d91d8c1 ♻️ (core): Evita doble clonado de InRegion en Child
Los prototipos registrados con `InRegion` se clonaban dos veces por
petición: una al ensamblar la región (`ComponentGlobal::as_child`) y
otra al renderizar (`Child::render`). Ahora sólo se clonan al
renderizar.
2026-07-26 12:39:01 +02:00
ce6b4d2581 ♻️ (core): Simplifica Child a Arc<dyn Component>
Elimina el `RwLock<Box<dyn Component>>` interno ya que ningún punto del
ciclo de renderizado tomaba el write-lock.

De paso, `From<Embed<C>> for Child` pasa a una coerción de puntero sin
clonar, gracias al copy-on-write que ya ofrece `Embed::get_mut()`. Los
doctests en `rust,ignore` pasan a `rust,no_run`, verificados por el
compilador.
2026-07-26 11:30:16 +02:00
65c8a788c2 (web): Añade cabeceras HTTP a TestRequest 2026-07-26 10:03:54 +02:00
39f494539e ♻️ (htmx): Expone request y response como públicos 2026-07-26 10:02:36 +02:00
8 changed files with 154 additions and 114 deletions

View file

@ -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";
}

View file

@ -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;
}

View file

@ -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.

View file

@ -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
///

View file

@ -1,5 +1,5 @@
use pagetop::prelude::*;
use pagetop_htmx::HtmxRequestExt;
use pagetop_htmx::prelude::*;
struct TestApp;

View file

@ -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<dyn Component>`, 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<Arc<RwLock<Box<dyn Component>>>>);
pub struct Child(Option<Arc<dyn Component>>);
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<dyn Component>) -> 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<C: Component>(mut self, component: Option<C>) -> Self {
self.0 = component.map(|c| Arc::new(RwLock::new(Box::new(c) as Box<dyn Component>)));
self.0 = component.map(|c| Arc::new(c) as Arc<dyn Component>);
self
}
@ -45,7 +55,7 @@ impl Child {
/// Devuelve el identificador del componente, si existe y está definido.
#[inline]
pub fn id(&self) -> Option<String> {
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<UniqueId> {
self.0.as_ref().map(|c| c.read().type_id())
self.0.as_ref().map(|c| c.type_id())
}
}
impl<C: Component + 'static> From<Embed<C>> for Child {
impl<C: Component> From<Embed<C>> for Child {
/// Convierte un [`Embed<C>`] 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<C>) -> 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<dyn Component>,
Err(arc) => arc.clone_box(),
})))),
}
Child(embed.0.map(|arc| arc as Arc<dyn Component>))
}
}
impl<T: Component + 'static> From<T> for Child {
impl<T: Component> From<T> for Child {
/// Convierte cualquier componente en un [`Child`], equivalente a [`Child::with()`].
#[inline]
fn from(component: T) -> Self {
Child::with(component)
}
}
impl<T: Component + 'static> From<T> for ChildOp {
impl<T: Component> From<T> 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<Child>);
@ -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<I>(&mut self, iter: I) -> &mut Self
pub(crate) fn add_many<I>(&mut self, iter: I) -> &mut Self
where
I: IntoIterator<Item = Child>,
{
@ -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);
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// async fn render_all(mut children: Children, context: &mut Context) {
/// for child in &mut children {
/// child.render(&mut context).await;
/// child.render(context).await;
/// }
/// }
/// ```
fn into_iter(self) -> Self::IntoIter {

View file

@ -7,26 +7,39 @@ 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<Arc<dyn Component>>)>);
impl RegionComponents {
// Devuelve los prototipos registrados para la región indicada, si hay alguno.
fn get(&self, region_name: &str) -> Option<&Vec<Arc<dyn Component>>> {
self.0
.iter()
.find(|(name, _)| *name == region_name)
.map(|(_, protos)| protos)
}
impl<T: Component + Clone + 'static> ComponentGlobal for T {
#[inline]
fn as_child(&self) -> Child {
Child::with(self.clone())
// Añade un prototipo a la región indicada, creando la entrada si es la primera.
//
// Se comparte como `Arc<dyn Component>`. 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<dyn Component>) {
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<String, Vec<Arc<dyn ComponentGlobal>>>;
// Regiones globales con prototipos asociados a un tema específico.
static THEME_REGIONS: LazyLock<RwLock<HashMap<UniqueId, RegionComponents>>> =
@ -34,13 +47,13 @@ static THEME_REGIONS: LazyLock<RwLock<HashMap<UniqueId, RegionComponents>>> =
// Regiones globales con prototipos comunes a todos los temas.
static COMMON_REGIONS: LazyLock<RwLock<RegionComponents>> =
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<String, Children>);
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<dyn ComponentGlobal> = Arc::new(component);
pub fn add(&self, component: impl Component) -> &Self {
let proto: Arc<dyn Component> = 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<dyn ComponentGlobal>) {
COMMON_REGIONS
.write()
.entry(region.name().to_owned())
.or_default()
.push(proto);
fn add_to_common(region: RegionRef, proto: Arc<dyn Component>) {
COMMON_REGIONS.write().push(region.name(), proto);
}
}

View file

@ -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<str>, value: impl AsRef<str>) -> 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),
}
}