feat(core): compose effect collections

Support Vec<T: IntoEffect> and [T; N] batches so dynamic reusable partial updates compose inside the existing effect model without a broad IntoIterator blanket impl.

req: canonical_authoring/003
This commit is contained in:
slhx agent
2026-06-12 14:36:58 +02:00
parent f343e8aaee
commit 8f96169b03
5 changed files with 44 additions and 3 deletions
+2 -1
View File
@@ -40,7 +40,8 @@ For beginner and production-shaped app code, stay on this path. req: public_api/
also carries the stable row key. A list target inside `h-for` must have a
stable `h-key`, so row updates are addressable without CSS selectors. req: list/001
- **Effect:** handlers return typed commands that become a checked effect
response. Tuple composition is the normal batch syntax.
response. Tuple composition is the normal fixed batch syntax; arrays and
`Vec<T: IntoEffect>` cover fixed or dynamic repeated partial updates.
- **Runtime:** the browser checks the build fingerprint, resolves targets within
the current `data-hemx-root`, and applies compatible batches. Mismatched
server/runtime builds fail closed instead of silently mutating the wrong DOM.
+1 -1
View File
@@ -53,7 +53,7 @@ client app state framework.
002 Typed partial swaps are the primary UX, not an advanced feature: handlers change domain state in Rust, convert domain values into view values, render hemplate partials through generated helpers, and place them into generated targets. The real primitive is generated target + rendered partial + swap kind. [north_star]
### req: canonical/003
003 Canonical keyed-row CRUD reads like ordinary Rust intent: create appends a rendered row partial, update/toggle replaces a keyed row partial, delete removes a keyed row, summary/text/form effects compose in tuples or arrays implementing `IntoEffect`, and no handler chooses a target with a CSS selector. [north_star]
003 Canonical keyed-row CRUD reads like ordinary Rust intent: create appends a rendered row partial, update/toggle replaces a keyed row partial, delete removes a keyed row, summary/text/form effects compose in tuples, arrays, or `Vec<T: IntoEffect>` for dynamic batches, and no handler chooses a target with a CSS selector. [north_star]
### req: canonical/004
004 Generated helpers may compose only facts uniquely known from templates and checked Rust types: template, slot, optional key, form/control, class token, explicit island/event marker, and effect kind. If a handler parameter, key, form, target, raw route, or legacy target would require guessing, the user must say it explicitly and diagnostics must point to the Rust and hemplate spans. [north_star]
+1 -1
View File
@@ -11,7 +11,7 @@ Open <http://127.0.0.1:3000>.
The page includes working examples for generated target objects and server-first UI commands:
- counter updates
- todo form submission with one `TodoRow` hemplate partial reused by the initial page render and generated row append/replace/remove commands
- todo form submission with one `TodoRow` hemplate partial reused by the initial page render, generated row append/replace/remove commands, and dynamic `Vec<T: IntoEffect>` row batches
- wizard step updates
- login form feedback
- page swap/navigation
+16
View File
@@ -850,6 +850,22 @@ impl<T: IntoEffect> IntoEffect for Option<T> {
}
}
impl<T: IntoEffect> IntoEffect for Vec<T> {
fn append_to(self, ops: &mut Vec<Effect>) {
for effect in self {
effect.append_to(ops);
}
}
}
impl<T: IntoEffect, const N: usize> IntoEffect for [T; N] {
fn append_to(self, ops: &mut Vec<Effect>) {
for effect in self {
effect.append_to(ops);
}
}
}
macro_rules! impl_tuple_into_effect {
($($name:ident $idx:tt),+) => {
impl<$($name),+> IntoEffect for ($($name,)+)
+24
View File
@@ -38,6 +38,30 @@ fn optional_effects_compose_into_batches() {
assert_eq!(batch.ops[0], count.text(2));
}
#[test]
fn effect_collections_compose_into_batches() {
// req: canonical_authoring/003
let rows = KeyedSlot::<u64, String>::new(2);
let summary = Slot::<u32>::new(3);
let notice = Slot::<String>::new(4);
let dynamic_rows = [7, 8]
.into_iter()
.map(|id| rows.replace_text(id, format!("todo {id}")))
.collect::<Vec<_>>();
let fixed_notices = [notice.text("Saved"), notice.text("Synced")];
let batch =
(dynamic_rows, Some(summary.text(2)), fixed_notices).into_batch(BuildFingerprint(42));
assert_eq!(batch.ops.len(), 5);
assert_eq!(batch.ops[0], rows.replace_text(7, String::from("todo 7")));
assert_eq!(batch.ops[1], rows.replace_text(8, String::from("todo 8")));
assert_eq!(batch.ops[2], summary.text(2));
assert_eq!(batch.ops[3], notice.text("Saved"));
assert_eq!(batch.ops[4], notice.text("Synced"));
}
#[test]
fn keyed_slot_replace_uses_scoped_resource_ref() {
let todos = KeyedSlot::<u64, String>::new(9);