fix(build): dispatch slot targets to matching component render/put

req: runtime/005

req: examples/001

req: htmx_equivalents/001
This commit is contained in:
slhx agent
2026-06-23 15:06:08 +02:00
parent cfa4502748
commit 329f16aebf
5 changed files with 131 additions and 23 deletions
Generated
+1
View File
@@ -618,6 +618,7 @@ dependencies = [
name = "hemx-test" name = "hemx-test"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"hemx-build",
"hemx-core", "hemx-core",
] ]
+6 -1
View File
@@ -739,7 +739,12 @@ mod tests {
.unwrap() .unwrap()
.batch, .batch,
); );
assert!(edit.updates_html_containing(gallery::contact_card, "save_contact")); // Component-level rendering now lowers contact_card-local handles, so the
// effect contains the lowered handle id, not the source name.
assert!(edit.updates_html_containing(
gallery::contact_card,
&format!("data-hid=\"{}\"", contact_card::save_contact),
));
let save = inspect_batch( let save = inspect_batch(
InteractionRequest::from(form( InteractionRequest::from(form(
+76 -22
View File
@@ -539,16 +539,20 @@ impl Resources {
)); ));
// req: build/001 req: component/003 // req: build/001 req: component/003
let component_names: BTreeSet<String> = self.component_names().into_iter().collect();
if global_exports { if global_exports {
self.push_component_refs(&mut out, 0); self.push_component_refs(&mut out, 0);
self.push_resource_modules(&mut out, None, 0); self.push_resource_modules(&mut out, &component_names, None, 0);
} }
self.push_lowering_api(&mut out, None, 0); self.push_lowering_api(&mut out, None, 0);
for component in self.component_names() { for component in self.component_names() {
out.push_str("\n#[allow(non_upper_case_globals)]\n"); out.push_str("\n#[allow(non_upper_case_globals)]\n");
out.push_str(&format!("pub mod {component} {{\n")); out.push_str(&format!("pub mod {component} {{\n"));
self.push_resource_modules(&mut out, Some(&component), 1); out.push_str(" #[derive(Clone, Copy)]\n");
out.push_str(" pub struct Component;\n\n");
self.push_resource_modules(&mut out, &component_names, Some(&component), 1);
self.push_lowering_api(&mut out, Some(&component), 1); self.push_lowering_api(&mut out, Some(&component), 1);
out.push_str("}\n"); out.push_str("}\n");
} }
@@ -572,7 +576,13 @@ impl Resources {
out.push_str(&format!("{pad}}}\n")); out.push_str(&format!("{pad}}}\n"));
} }
fn push_resource_modules(&self, out: &mut String, component: Option<&str>, indent: usize) { fn push_resource_modules(
&self,
out: &mut String,
components: &BTreeSet<String>,
component: Option<&str>,
indent: usize,
) {
let pad = " ".repeat(indent); let pad = " ".repeat(indent);
let inner = " ".repeat(indent + 1); let inner = " ".repeat(indent + 1);
let mut handle_ids = Vec::new(); let mut handle_ids = Vec::new();
@@ -613,34 +623,53 @@ impl Resources {
out.push_str(&format!( out.push_str(&format!(
"{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod targets {{\n" "{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod targets {{\n"
)); ));
let child_prefix = if component.is_some() {
"super::super::"
} else {
"super::"
};
out.push_str(&format!("{inner}#[derive(Clone, Copy)]\n")); out.push_str(&format!("{inner}#[derive(Clone, Copy)]\n"));
out.push_str(&format!( out.push_str(&format!(
"{inner}pub struct SlotTarget<T> {{ slot: ::hemx::advanced::Slot<T> }}\n" "{inner}pub struct SlotTarget<T, C = ()> {{ slot: ::hemx::advanced::Slot<T>, _marker: ::std::marker::PhantomData<C> }}\n"
)); ));
out.push_str(&format!("{inner}#[allow(dead_code)]\n")); out.push_str(&format!("{inner}#[allow(dead_code)]\n"));
out.push_str(&format!("{inner}impl<T> SlotTarget<T> {{\n")); out.push_str(&format!("{inner}impl<T, C> SlotTarget<T, C> {{\n"));
out.push_str(&format!("{inner} const fn new(slot: ::hemx::advanced::Slot<T>) -> Self {{ Self {{ slot }} }}\n")); out.push_str(&format!("{inner} const fn new(slot: ::hemx::advanced::Slot<T>) -> Self {{ Self {{ slot, _marker: ::std::marker::PhantomData }} }}\n"));
out.push_str(&format!("{inner} pub fn put(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ super::put(self.slot, view) }}\n"));
out.push_str(&format!("{inner} pub fn replace(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ super::put(self.slot, view) }}\n"));
out.push_str(&format!("{inner} pub fn text(self, value: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.text(value) }}\n")); out.push_str(&format!("{inner} pub fn text(self, value: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.text(value) }}\n"));
out.push_str(&format!("{inner} pub fn set(self, value: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.text(value) }}\n")); out.push_str(&format!("{inner} pub fn set(self, value: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.text(value) }}\n"));
out.push_str(&format!("{inner}}}\n")); out.push_str(&format!("{inner}}}\n"));
out.push_str(&format!("{inner}impl<T> SlotTarget<T, ()> {{\n"));
out.push_str(&format!("{inner} pub fn put(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ super::put(self.slot, view) }}\n"));
out.push_str(&format!("{inner} pub fn replace(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ super::put(self.slot, view) }}\n"));
out.push_str(&format!("{inner}}}\n"));
for res in self
.slots
.values()
.filter(|res| component_matches(res, component))
.filter(|res| components.contains(&res.ident) && Some(res.ident.as_str()) != component)
{
let child = &res.ident;
out.push_str(&format!("{inner}impl<T> SlotTarget<T, {child_prefix}{child}::Component> {{\n"));
out.push_str(&format!("{inner} pub fn put(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ {child_prefix}{child}::put(self.slot, view) }}\n"));
out.push_str(&format!("{inner} pub fn replace(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ {child_prefix}{child}::put(self.slot, view) }}\n"));
out.push_str(&format!("{inner}}}\n"));
}
out.push_str(&format!( out.push_str(&format!(
"{inner}impl<T> ::hemx::GeneratedTarget for SlotTarget<T> {{\n" "{inner}impl<T, C> ::hemx::GeneratedTarget for SlotTarget<T, C> {{\n"
)); ));
out.push_str(&format!("{inner} fn __hemx_resource_id(self) -> ::hemx::advanced::ResourceId {{ self.slot.id() }}\n")); out.push_str(&format!("{inner} fn __hemx_resource_id(self) -> ::hemx::advanced::ResourceId {{ self.slot.id() }}\n"));
out.push_str(&format!("{inner}}}\n")); out.push_str(&format!("{inner}}}\n"));
out.push_str(&format!("{inner}#[derive(Clone, Copy)]\n")); out.push_str(&format!("{inner}#[derive(Clone, Copy)]\n"));
out.push_str(&format!("{inner}pub struct KeyedSlotTarget<K, T> {{ slot: ::hemx::advanced::KeyedSlot<K, T> }}\n")); out.push_str(&format!("{inner}pub struct KeyedSlotTarget<K, T, C = ()> {{ slot: ::hemx::advanced::KeyedSlot<K, T>, _marker: ::std::marker::PhantomData<C> }}\n"));
out.push_str(&format!("{inner}#[allow(dead_code)]\n")); out.push_str(&format!("{inner}#[allow(dead_code)]\n"));
out.push_str(&format!("{inner}impl<K, T> KeyedSlotTarget<K, T>\n")); out.push_str(&format!("{inner}impl<K, T, C> KeyedSlotTarget<K, T, C>\n"));
out.push_str(&format!("{inner}where\n")); out.push_str(&format!("{inner}where\n"));
out.push_str(&format!("{inner} K: ::std::string::ToString,\n")); out.push_str(&format!("{inner} K: ::std::string::ToString,\n"));
out.push_str(&format!("{inner}{{\n")); out.push_str(&format!("{inner}{{\n"));
out.push_str(&format!("{inner} const fn new(slot: ::hemx::advanced::KeyedSlot<K, T>) -> Self {{ Self {{ slot }} }}\n")); out.push_str(&format!("{inner} const fn new(slot: ::hemx::advanced::KeyedSlot<K, T>) -> Self {{ Self {{ slot, _marker: ::std::marker::PhantomData }} }}\n"));
out.push_str(&format!("{inner}}}\n")); out.push_str(&format!("{inner}}}\n"));
out.push_str(&format!( out.push_str(&format!(
"{inner}impl<T> KeyedSlotTarget<::std::string::String, T> {{\n" "{inner}impl<T> KeyedSlotTarget<::std::string::String, T, ()> {{\n"
)); ));
out.push_str(&format!("{inner} pub fn append(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.append_html(view.hemx_key(), super::render(&view)) }}\n")); out.push_str(&format!("{inner} pub fn append(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.append_html(view.hemx_key(), super::render(&view)) }}\n"));
out.push_str(&format!("{inner} pub fn prepend(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.prepend_html(view.hemx_key(), super::render(&view)) }}\n")); out.push_str(&format!("{inner} pub fn prepend(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.prepend_html(view.hemx_key(), super::render(&view)) }}\n"));
@@ -650,8 +679,28 @@ impl Resources {
out.push_str(&format!("{inner} pub fn replace_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ self.slot.replace_html(key.to_string(), super::render(view)) }}\n")); out.push_str(&format!("{inner} pub fn replace_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ self.slot.replace_html(key.to_string(), super::render(view)) }}\n"));
out.push_str(&format!("{inner} pub fn remove(self, key: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.remove(key.to_string()) }}\n")); out.push_str(&format!("{inner} pub fn remove(self, key: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.remove(key.to_string()) }}\n"));
out.push_str(&format!("{inner}}}\n")); out.push_str(&format!("{inner}}}\n"));
for res in self
.slots
.values()
.filter(|res| component_matches(res, component))
.filter(|res| res.keyed)
.filter(|res| components.contains(&res.ident) && Some(res.ident.as_str()) != component)
{
let child = &res.ident;
out.push_str(&format!(
"{inner}impl<T> KeyedSlotTarget<::std::string::String, T, {child_prefix}{child}::Component> {{\n"
));
out.push_str(&format!("{inner} pub fn append(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.append_html(view.hemx_key(), {child_prefix}{child}::render(&view)) }}\n"));
out.push_str(&format!("{inner} pub fn prepend(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.prepend_html(view.hemx_key(), {child_prefix}{child}::render(&view)) }}\n"));
out.push_str(&format!("{inner} pub fn replace(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.replace_html(view.hemx_key(), {child_prefix}{child}::render(&view)) }}\n"));
out.push_str(&format!("{inner} pub fn append_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ self.slot.append_html(key.to_string(), {child_prefix}{child}::render(view)) }}\n"));
out.push_str(&format!("{inner} pub fn prepend_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ self.slot.prepend_html(key.to_string(), {child_prefix}{child}::render(view)) }}\n"));
out.push_str(&format!("{inner} pub fn replace_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ self.slot.replace_html(key.to_string(), {child_prefix}{child}::render(view)) }}\n"));
out.push_str(&format!("{inner} pub fn remove(self, key: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.remove(key.to_string()) }}\n"));
out.push_str(&format!("{inner}}}\n"));
}
out.push_str(&format!( out.push_str(&format!(
"{inner}impl<K: ::std::string::ToString, T> ::hemx::GeneratedTarget for KeyedSlotTarget<K, T> {{\n" "{inner}impl<K: ::std::string::ToString, T, C> ::hemx::GeneratedTarget for KeyedSlotTarget<K, T, C> {{\n"
)); ));
out.push_str(&format!("{inner} fn __hemx_resource_id(self) -> ::hemx::advanced::ResourceId {{ self.slot.id() }}\n")); out.push_str(&format!("{inner} fn __hemx_resource_id(self) -> ::hemx::advanced::ResourceId {{ self.slot.id() }}\n"));
out.push_str(&format!("{inner}}}\n")); out.push_str(&format!("{inner}}}\n"));
@@ -660,14 +709,19 @@ impl Resources {
.values() .values()
.filter(|res| component_matches(res, component)) .filter(|res| component_matches(res, component))
{ {
let marker = if components.contains(&res.ident) && Some(res.ident.as_str()) != component {
format!("{child_prefix}{}::Component", res.ident)
} else {
"()".to_string()
};
if res.keyed { if res.keyed {
out.push_str(&format!( out.push_str(&format!(
"{inner}pub const {}: KeyedSlotTarget<::std::string::String, ::std::string::String> = KeyedSlotTarget::new(super::advanced::slots::{});\n", "{inner}pub const {}: KeyedSlotTarget<::std::string::String, ::std::string::String, {marker}> = KeyedSlotTarget::new(super::advanced::slots::{});\n",
res.ident, res.ident res.ident, res.ident
)); ));
} else { } else {
out.push_str(&format!( out.push_str(&format!(
"{inner}pub const {}: SlotTarget<::std::string::String> = SlotTarget::new(super::advanced::slots::{});\n", "{inner}pub const {}: SlotTarget<::std::string::String, {marker}> = SlotTarget::new(super::advanced::slots::{});\n",
res.ident, res.ident res.ident, res.ident
)); ));
} }
@@ -2127,10 +2181,10 @@ mod tests {
assert!(generated.contains("pub mod slots")); assert!(generated.contains("pub mod slots"));
assert!(generated.contains("pub const todos")); assert!(generated.contains("pub const todos"));
assert!(generated.contains("pub mod targets")); assert!(generated.contains("pub mod targets"));
assert!(generated.contains("pub struct SlotTarget<T>")); assert!(generated.contains("pub struct SlotTarget<T, C = ()>"));
assert!(generated.contains("impl<T> ::hemx::GeneratedTarget for SlotTarget<T>")); assert!(generated.contains("impl<T, C> ::hemx::GeneratedTarget for SlotTarget<T, C>"));
assert!(generated.contains( assert!(generated.contains(
"impl<K: ::std::string::ToString, T> ::hemx::GeneratedTarget for KeyedSlotTarget<K, T>" "impl<K: ::std::string::ToString, T, C> ::hemx::GeneratedTarget for KeyedSlotTarget<K, T, C>"
)); ));
assert!(generated.contains( assert!(generated.contains(
"pub fn append(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect" "pub fn append(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect"
@@ -2138,7 +2192,7 @@ mod tests {
assert!(generated.contains( assert!(generated.contains(
"pub fn append_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect" "pub fn append_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect"
)); ));
assert!(generated.contains("pub const todos: SlotTarget<::std::string::String> = SlotTarget::new(super::advanced::slots::todos);")); assert!(generated.contains("pub const todos: SlotTarget<::std::string::String, ()> = SlotTarget::new(super::advanced::slots::todos);"));
assert!(generated.contains("pub use self::targets::todos;")); assert!(generated.contains("pub use self::targets::todos;"));
assert!(generated.contains( assert!(generated.contains(
"pub fn put(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect" "pub fn put(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect"
@@ -2253,7 +2307,7 @@ mod tests {
assert!(generated.contains("pub mod todo")); assert!(generated.contains("pub mod todo"));
assert!(generated.contains("pub const create: ::hemx::Handle<()> = ::hemx::Handle::new(")); assert!(generated.contains("pub const create: ::hemx::Handle<()> = ::hemx::Handle::new("));
assert!(generated.contains("pub const todos: ::hemx::advanced::Slot<::std::string::String> = ::hemx::advanced::Slot::new(")); assert!(generated.contains("pub const todos: ::hemx::advanced::Slot<::std::string::String> = ::hemx::advanced::Slot::new("));
assert!(generated.contains("pub const todos: SlotTarget<::std::string::String> = SlotTarget::new(super::advanced::slots::todos);")); assert!(generated.contains("pub const todos: SlotTarget<::std::string::String, ()> = SlotTarget::new(super::advanced::slots::todos);"));
assert!(generated.contains("pub use self::targets::todos;")); assert!(generated.contains("pub use self::targets::todos;"));
assert!(generated assert!(generated
.contains("pub const click: ::hemx::EventName = ::hemx::EventName::new(\"click\")")); .contains("pub const click: ::hemx::EventName = ::hemx::EventName::new(\"click\")"));
@@ -2301,7 +2355,7 @@ mod tests {
assert!(generated.contains(" pub mod advanced")); assert!(generated.contains(" pub mod advanced"));
assert!(generated.contains(" pub mod slots")); assert!(generated.contains(" pub mod slots"));
assert!(generated.contains(" pub mod targets")); assert!(generated.contains(" pub mod targets"));
assert!(generated.contains(" pub const todos: SlotTarget<::std::string::String> = SlotTarget::new(super::advanced::slots::todos);")); assert!(generated.contains(" pub const todos: SlotTarget<::std::string::String, ()> = SlotTarget::new(super::advanced::slots::todos);"));
assert!(generated.contains(" pub use self::targets::todos;")); assert!(generated.contains(" pub use self::targets::todos;"));
assert!(generated.contains(" #[doc(hidden)]\n pub fn lower_html")); assert!(generated.contains(" #[doc(hidden)]\n pub fn lower_html"));
assert!(generated.contains( assert!(generated.contains(
+3
View File
@@ -5,3 +5,6 @@ edition.workspace = true
[dependencies] [dependencies]
hemx-core = { path = "../hemx-core" } hemx-core = { path = "../hemx-core" }
[dev-dependencies]
hemx-build = { path = "../hemx-build" }
+45
View File
@@ -278,6 +278,51 @@ fn browser_e2e_does_not_shortcut_product_interactions() {
); );
} }
#[test]
fn html_examples_slot_targets_dispatch_to_component_put() {
// req: runtime/005 req: examples/001 req: htmx_equivalents/001
// Regression: slots named after a component (e.g. contact_card, editable_row)
// must lower injected partials using that component's handle table, not the
// containing page's handle table.
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let template_dir = root.join("examples/html_examples/templates");
let out_dir = std::env::temp_dir().join(format!(
"hemx-test-html-examples-slot-targets-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&out_dir);
std::fs::create_dir_all(&out_dir).unwrap();
let result = std::panic::catch_unwind(|| {
hemx_build::app()
.template_dir(&template_dir)
.out_dir(&out_dir)
.run()
.expect("hemx-build should generate html_examples artifacts");
let generated =
std::fs::read_to_string(out_dir.join("hemx.generated.rs")).unwrap_or_default();
for component in ["contact_card", "editable_row"] {
let component_path = format!("super::super::{component}");
let impl_header = format!("impl<T> SlotTarget<T, {component_path}::Component>");
let put_call = format!("{component_path}::put(self.slot, view)");
assert!(
generated.contains(&impl_header),
"gallery::targets should have a component-specific SlotTarget impl for `{component}`; missing `{impl_header}`"
);
assert!(
generated.contains(&put_call),
"gallery::targets::{component} put/replace should dispatch to `{component_path}::put`; missing `{put_call}`"
);
}
});
let _ = std::fs::remove_dir_all(&out_dir);
result.unwrap();
}
fn scan_examples(dir: &Path, visit: &mut impl FnMut(&Path, &str)) { fn scan_examples(dir: &Path, visit: &mut impl FnMut(&Path, &str)) {
for entry in std::fs::read_dir(dir).unwrap() { for entry in std::fs::read_dir(dir).unwrap() {
let entry = entry.unwrap(); let entry = entry.unwrap();