feat(axum): add interaction registry alias

Add an interactions()/on() registration path and move canonical examples off explicit register_handle naming while keeping low-level registry types available underneath.

req: axum_integration/003

req: ceremony/001

req: dx/003

req: examples/003
This commit is contained in:
slhx agent
2026-06-02 00:24:32 +02:00
parent f388fa7d91
commit df4b4cc649
7 changed files with 41 additions and 25 deletions
+1 -1
View File
@@ -441,7 +441,7 @@ what a valid business email is.
002 Existing Axum routes remain normal Axum routes. slhx does not own routing. slhx-axum only mounts handler dispatch, runtime assets, and optional push endpoints. 002 Existing Axum routes remain normal Axum routes. slhx does not own routing. slhx-axum only mounts handler dispatch, runtime assets, and optional push endpoints.
### req: axum/003 ### req: axum/003
003 Interactive fragments that would traditionally be implemented as `/demo/...` HTMX endpoints should be expressible as `#[slhx::handler]` functions returning generated slot commands. 003 Interactive fragments that would traditionally be implemented as `/demo/...` HTMX endpoints should be expressible as `#[slhx::handler]` functions returning generated slot commands. Integration registration should read as interactions over generated handles, not low-level registry wiring.
### req: axum/004 ### req: axum/004
004 Query-string demo endpoints may be migrated to typed handler params from `data-*` attributes or forms. `Query<T>` remains available in normal Axum routes but is not the slhx happy path. 004 Query-string demo endpoints may be migrated to typed handler params from `data-*` attributes or forms. `Query<T>` remains available in normal Axum routes but is not the slhx happy path.
+6 -6
View File
@@ -6,7 +6,7 @@ use futures_util::{stream, StreamExt};
use hemplate::Hemplate; use hemplate::Hemplate;
use slhx::{IntoEffect, RenderSlotExt, SafeHtml}; use slhx::{IntoEffect, RenderSlotExt, SafeHtml};
use slhx_axum::{ use slhx_axum::{
handlers, runtime_js, sse, DispatchRegistry, DispatchRejection, EffectResponse, interactions, runtime_js, sse, DispatchRegistry, DispatchRejection, EffectResponse,
InteractionRequest, PageRequest, InteractionRequest, PageRequest,
}; };
use slhx_kanban_example::ui::{self, board as board_ui}; use slhx_kanban_example::ui::{self, board as board_ui};
@@ -152,8 +152,8 @@ async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResp
} }
fn registry(state: Arc<AppState>) -> impl DispatchRegistry { fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
handlers(ui::BUILD_FINGERPRINT) interactions(ui::BUILD_FINGERPRINT)
.register_handle(handles::create_card, { .on(handles::create_card, {
let state = state.clone(); let state = state.clone();
move |form| { move |form| {
// req: examples/001 req: form/002 // req: examples/001 req: form/002
@@ -168,7 +168,7 @@ fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
board_effects(&board, "Card added") board_effects(&board, "Card added")
} }
}) })
.register_handle(card_handles::move_left, { .on(card_handles::move_left, {
let state = state.clone(); let state = state.clone();
move |form| { move |form| {
// req: examples/001 req: list/003 // req: examples/001 req: list/003
@@ -179,7 +179,7 @@ fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
board_effects(&board, if moved { "Card moved left" } else { "Card not found" }) board_effects(&board, if moved { "Card moved left" } else { "Card not found" })
} }
}) })
.register_handle(card_handles::move_right, { .on(card_handles::move_right, {
let state = state.clone(); let state = state.clone();
move |form| { move |form| {
// req: examples/001 req: list/003 // req: examples/001 req: list/003
@@ -190,7 +190,7 @@ fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
board_effects(&board, if moved { "Card moved right" } else { "Card not found" }) board_effects(&board, if moved { "Card moved right" } else { "Card not found" })
} }
}) })
.register_handle(card_handles::delete_card, { .on(card_handles::delete_card, {
let state = state.clone(); let state = state.clone();
move |form| { move |form| {
// req: examples/001 req: list/003 // req: examples/001 req: list/003
+9 -9
View File
@@ -6,7 +6,7 @@ use futures_util::{stream, StreamExt};
use hemplate::Hemplate; use hemplate::Hemplate;
use slhx::{CssClass, CssClasses, IntoEffect, RenderSlotExt, SafeHtml}; use slhx::{CssClass, CssClasses, IntoEffect, RenderSlotExt, SafeHtml};
use slhx_axum::{ use slhx_axum::{
handlers, runtime_js, sse, DispatchRegistry, DispatchRejection, EffectResponse, interactions, runtime_js, sse, DispatchRegistry, DispatchRejection, EffectResponse,
InteractionRequest, PageRequest, InteractionRequest, PageRequest,
}; };
use slhx_techdemo::ui; use slhx_techdemo::ui;
@@ -283,8 +283,8 @@ async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResp
} }
fn registry(shared: Arc<Shared>) -> impl DispatchRegistry { fn registry(shared: Arc<Shared>) -> impl DispatchRegistry {
handlers(ui::BUILD_FINGERPRINT) interactions(ui::BUILD_FINGERPRINT)
.register_handle(handles::launch_work, { .on(handles::launch_work, {
let shared = shared.clone(); let shared = shared.clone();
move |form| { move |form| {
// req: form/002 req: examples/001 // req: form/002 req: examples/001
@@ -303,7 +303,7 @@ fn registry(shared: Arc<Shared>) -> impl DispatchRegistry {
demo_effects(&demo, "Launch accepted · 4 targets updated") demo_effects(&demo, "Launch accepted · 4 targets updated")
} }
}) })
.register_handle(card_handles::advance_work, { .on(card_handles::advance_work, {
let shared = shared.clone(); let shared = shared.clone();
move |form| { move |form| {
// req: list/003 req: examples/001 // req: list/003 req: examples/001
@@ -321,7 +321,7 @@ fn registry(shared: Arc<Shared>) -> impl DispatchRegistry {
demo_effects(&demo, "Pipeline advanced") demo_effects(&demo, "Pipeline advanced")
} }
}) })
.register_handle(lane_handles::move_to_lane, { .on(lane_handles::move_to_lane, {
let shared = shared.clone(); let shared = shared.clone();
move |form| { move |form| {
// req: list/003 req: examples/001 // req: list/003 req: examples/001
@@ -343,7 +343,7 @@ fn registry(shared: Arc<Shared>) -> impl DispatchRegistry {
demo_effects(&demo, "Drag-and-drop move persisted") demo_effects(&demo, "Drag-and-drop move persisted")
} }
}) })
.register_handle(card_handles::delete_work, { .on(card_handles::delete_work, {
let shared = shared.clone(); let shared = shared.clone();
move |form| { move |form| {
// req: list/003 req: examples/001 // req: list/003 req: examples/001
@@ -362,7 +362,7 @@ fn registry(shared: Arc<Shared>) -> impl DispatchRegistry {
demo_effects(&demo, "Card removed") demo_effects(&demo, "Card removed")
} }
}) })
.register_handle(card_handles::spotlight_work, { .on(card_handles::spotlight_work, {
let shared = shared.clone(); let shared = shared.clone();
move |form| { move |form| {
// req: examples/001 // req: examples/001
@@ -377,7 +377,7 @@ fn registry(shared: Arc<Shared>) -> impl DispatchRegistry {
demo_effects(&demo, "Inspector focused") demo_effects(&demo, "Inspector focused")
} }
}) })
.register_handle(handles::simulate_push, { .on(handles::simulate_push, {
let shared = shared.clone(); let shared = shared.clone();
move |_| { move |_| {
// req: push/003 req: examples/001 // req: push/003 req: examples/001
@@ -393,7 +393,7 @@ fn registry(shared: Arc<Shared>) -> impl DispatchRegistry {
) )
} }
}) })
.register_handle(handles::reset_demo, { .on(handles::reset_demo, {
let shared = shared.clone(); let shared = shared.clone();
move |_| { move |_| {
// req: examples/001 // req: examples/001
+7 -7
View File
@@ -6,7 +6,7 @@ use futures_util::{stream, StreamExt};
use hemplate::Hemplate; use hemplate::Hemplate;
use slhx::{push, IntoEffect, RenderSlotExt, SafeHtml}; use slhx::{push, IntoEffect, RenderSlotExt, SafeHtml};
use slhx_axum::{ use slhx_axum::{
handlers, runtime_js, sse, DispatchRegistry, EffectResponse, InteractionRequest, PageRequest, interactions, runtime_js, sse, DispatchRegistry, EffectResponse, InteractionRequest, PageRequest,
}; };
use slhx_v0_examples::ui; use slhx_v0_examples::ui;
use slhx_v0_examples::ui::{auth, counter, notifications, page_swap, todos, wizard}; use slhx_v0_examples::ui::{auth, counter, notifications, page_swap, todos, wizard};
@@ -124,8 +124,8 @@ async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResp
} }
fn registry(state: Arc<ExampleState>) -> impl DispatchRegistry { fn registry(state: Arc<ExampleState>) -> impl DispatchRegistry {
handlers(ui::BUILD_FINGERPRINT) interactions(ui::BUILD_FINGERPRINT)
.register_handle(counter_handles::increment, { .on(counter_handles::increment, {
let state = state.clone(); let state = state.clone();
move |_| { move |_| {
// req: examples/001 // req: examples/001
@@ -134,7 +134,7 @@ fn registry(state: Arc<ExampleState>) -> impl DispatchRegistry {
counter_slots::counter_value.text(*counter) counter_slots::counter_value.text(*counter)
} }
}) })
.register_handle(todo_handles::add_todo, { .on(todo_handles::add_todo, {
let state = state.clone(); let state = state.clone();
move |form| { move |form| {
// req: examples/001 // req: examples/001
@@ -150,7 +150,7 @@ fn registry(state: Arc<ExampleState>) -> impl DispatchRegistry {
) )
} }
}) })
.register_handle(wizard_handles::next_step, { .on(wizard_handles::next_step, {
let state = state.clone(); let state = state.clone();
move |_| { move |_| {
// req: examples/001 // req: examples/001
@@ -159,7 +159,7 @@ fn registry(state: Arc<ExampleState>) -> impl DispatchRegistry {
wizard_slots::wizard_step.text(format!("Step {}", *step + 1)) wizard_slots::wizard_step.text(format!("Step {}", *step + 1))
} }
}) })
.register_handle(auth_handles::login, |form| { .on(auth_handles::login, |form| {
// req: examples/001 // req: examples/001
let ok = form.value("email") == Some("demo@example.com") let ok = form.value("email") == Some("demo@example.com")
&& form.value("password").is_some_and(|password| !password.is_empty()); && form.value("password").is_some_and(|password| !password.is_empty());
@@ -169,7 +169,7 @@ fn registry(state: Arc<ExampleState>) -> impl DispatchRegistry {
"Try demo@example.com with any password" "Try demo@example.com with any password"
}) })
}) })
.register_handle(page_handles::load_docs, |_| { .on(page_handles::load_docs, |_| {
// req: page_swap/002, req: examples/001 // req: page_swap/002, req: examples/001
( (
page_slots::content.render(&DocsContent { page_slots::content.render(&DocsContent {
+15
View File
@@ -266,6 +266,10 @@ pub const fn handlers(fingerprint: BuildFingerprint) -> HandlerRegistry {
HandlerRegistry::new(fingerprint) HandlerRegistry::new(fingerprint)
} }
pub const fn interactions(fingerprint: BuildFingerprint) -> HandlerRegistry {
HandlerRegistry::new(fingerprint)
}
impl InteractionRequest { impl InteractionRequest {
pub fn dispatch( pub fn dispatch(
self, self,
@@ -320,6 +324,17 @@ impl HandlerRegistry {
self.register(handle.id().id, handler) self.register(handle.id().id, handler)
} }
pub fn on<I, E>(
self,
handle: Handle<I>,
handler: impl Fn(InteractionForm) -> E + Send + Sync + 'static,
) -> Self
where
E: IntoEffect,
{
self.register_handle(handle, handler)
}
pub fn dispatch(&self, form: InteractionForm) -> Result<EffectResponse, DispatchRejection> { pub fn dispatch(&self, form: InteractionForm) -> Result<EffectResponse, DispatchRejection> {
let handle_id = form.handle_id; let handle_id = form.handle_id;
let Some(handler) = self.handlers.get(&handle_id) else { let Some(handler) = self.handlers.get(&handle_id) else {
+2 -2
View File
@@ -2,7 +2,7 @@ use axum::http::{header, HeaderMap};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use scraper::{Html, Selector}; use scraper::{Html, Selector};
use slhx_axum::{ use slhx_axum::{
handlers, runtime_js, DispatchRejection, EffectResponse, HandlerRegistry, InteractionForm, interactions, runtime_js, DispatchRejection, EffectResponse, HandlerRegistry, InteractionForm,
InteractionFormRejection, InteractionRequest, PageMode, PageRequest, PageResponse, InteractionFormRejection, InteractionRequest, PageMode, PageRequest, PageResponse,
SLHX_CONTENT_TYPE, SLHX_FINGERPRINT_HEADER, SLHX_PARTIAL_HEADER, SLHX_RUNTIME_CONTENT_TYPE, SLHX_CONTENT_TYPE, SLHX_FINGERPRINT_HEADER, SLHX_PARTIAL_HEADER, SLHX_RUNTIME_CONTENT_TYPE,
SLHX_TITLE_HEADER, SLHX_TITLE_HEADER,
@@ -148,7 +148,7 @@ fn interaction_request_dispatches_with_concise_handlers_helper() {
Vec::new(), Vec::new(),
)); ));
let response = request let response = request
.dispatch(handlers(BuildFingerprint(4)).register_handle(Handle::<()>::new(7), |_| { .dispatch(interactions(BuildFingerprint(4)).on(Handle::<()>::new(7), |_| {
Slot::<String>::new(3).text("ok") Slot::<String>::new(3).text("ok")
})) }))
.unwrap(); .unwrap();
+1
View File
@@ -65,6 +65,7 @@ fn canonical_examples_do_not_author_low_level_resource_plumbing() {
"render_html(", "render_html(",
"HandlerRegistry", "HandlerRegistry",
"InteractionForm", "InteractionForm",
"register_handle(",
"name=\"__h\"", "name=\"__h\"",
"name='__h'", "name='__h'",
]; ];