diff --git a/examples/todo/about.rs b/examples/todo/about.rs index 10edea8..ff996f3 100644 --- a/examples/todo/about.rs +++ b/examples/todo/about.rs @@ -16,7 +16,7 @@ pub async fn about(_cx: &mut RenderContext) -> anyhow::Result { nav { button.nav-link - data-on-click="$tree = {page: 'TodoList'}; @post('/render')" { + data-on:click="$tree = {page: 'TodoList'}; @post('/render')" { "Todos" } " | " diff --git a/examples/todo/app.rs b/examples/todo/app.rs index 2aceeb9..c0afc26 100644 --- a/examples/todo/app.rs +++ b/examples/todo/app.rs @@ -1,23 +1,19 @@ use livevue_rs::{ AppState, CacheKey, ComponentTree, RenderContext, SharedState, SignalStore, - format_patch_elements, format_patch_signals, sse_handler, + format_patch_elements, sse_handler, }; use axum::extract::{Query as AxumQuery, State}; -use axum::http::header; use axum::response::{Html, IntoResponse}; use axum::routing::{get, post}; use axum::{Json, Router}; +use axum::http::StatusCode; use uuid::Uuid; // --------------------------------------------------------------------------- // Router // --------------------------------------------------------------------------- -/// Build the axum Router with all routes. -/// -/// Lesson from v0.0: handlers live here next to the components they call, -/// not in a separate routes.rs with complex trait object signatures. pub fn router(state: SharedState) -> Router { Router::new() .route("/", get(initial_page_load)) @@ -34,27 +30,13 @@ pub fn router(state: SharedState) -> Router { // Document shell // --------------------------------------------------------------------------- -/// Wrap rendered component HTML in a full HTML document. +/// Full HTML document for initial page load. /// -/// Lesson from v0.0: Maud can't handle DataStar's attribute syntax -/// (e.g. `data-on-signal-change__debounce.300ms`). The document shell uses -/// format! / raw string. Maud is for component interiors only. -/// -/// DataStar v1 contract (verified against https://data-star.dev/reference): +/// DataStar v1 contract: /// - `data-signals` — initialize signals (JS object literal syntax) -/// - `data-on-signal-patch` — fires when any signal is patched (renamed from -/// `data-on-signal-change` which was removed in RC8) -/// - `__debounce.300ms` — modifier: debounce 300ms -/// - `@post('/render')` — send all non-_ signals as JSON body -/// - `@get('/sse?conn=...')` — open long-lived SSE pipe -/// - `data-on-load` — fires when element enters DOM -/// -/// KNOWN v0 QUIRK: Nav buttons use `$tree = {...}; @post('/render')`, -/// which fires an immediate POST. The signal change handler then fires a -/// second debounced POST 300ms later. This double-fire is harmless (same -/// HTML both times) but wasteful. Phase 2 could fix this with request -/// deduplication or by switching nav to only mutate the signal and relying -/// solely on the auto-poster. +/// - `data-on-signal-patch__debounce.300ms` — fires when any signal is patched, +/// debounced 300ms, posts all non-_ signals as JSON body to /render +/// - `data-init` — fires when element enters DOM, opens long-lived SSE pipe fn document_shell(conn_id: Uuid, initial_signals: &str, inner_html: &str) -> String { format!( r#" @@ -69,16 +51,29 @@ fn document_shell(conn_id: Uuid, initial_signals: &str, inner_html: &str) -> Str -
- {inner} -
+ {app} "#, css = INLINE_CSS, - signals = initial_signals, + app = app_wrapper(conn_id, initial_signals, inner_html), + ) +} + +/// The `
` wrapper with DataStar bindings. +/// +/// Extracted separately so `/render` can return just this element as +/// `text/html` — DataStar morphs it into the existing DOM by id. +fn app_wrapper(conn_id: Uuid, signals: &str, inner_html: &str) -> String { + format!( + r#"
+ {inner} +

+    
"#, + // Could __debounce.300ms on the signal patch + signals = signals, conn = conn_id, inner = inner_html, ) @@ -109,10 +104,6 @@ const INLINE_CSS: &str = r#" // Page resolution // --------------------------------------------------------------------------- -/// Match a page name (from the `tree` signal) to its component function. -/// -/// This is a plain match statement — no ComponentRegistry, no trait objects, -/// no async lifetime gymnastics. The right call for a v0 with two pages. async fn resolve_and_render(cx: &mut RenderContext) -> anyhow::Result { let markup = match cx.tree.page.as_str() { "TodoList" => crate::todo_list::todo_list(cx).await?, @@ -131,9 +122,7 @@ async fn resolve_and_render(cx: &mut RenderContext) -> anyhow::Result { Ok(markup.into_string()) } -/// Shared render pipeline used by both initial page load and POST /render. -/// -/// Returns `(rendered_html, subscription_keys, signals_json)`. +/// Shared render pipeline. Returns `(rendered_inner_html, subscription_keys, signals_json)`. async fn render_page( db: &sqlx::SqlitePool, conn_id: Uuid, @@ -147,6 +136,22 @@ async fn render_page( Ok((html, keys, signals_json)) } +/// Extract conn_id and tree from a signal store. +fn extract_conn_and_tree(signals: &SignalStore) -> (Uuid, ComponentTree) { + let conn_id = signals + .get("connId") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse().ok()) + .unwrap_or_else(Uuid::new_v4); + + let tree = signals + .get("tree") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + + (conn_id, tree) +} + // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- @@ -155,7 +160,6 @@ async fn render_page( async fn initial_page_load(State(state): State) -> impl IntoResponse { let conn_id = Uuid::new_v4(); - // Build initial signals let tree = ComponentTree { page: "TodoList".into(), ..Default::default() @@ -170,7 +174,6 @@ async fn initial_page_load(State(state): State) -> impl IntoRespons match render_page(&state.db, conn_id, signals, tree).await { Ok((html, _keys, _signals_json)) => { - // Keys and signals_json are stored when SSE connects Html(document_shell(conn_id, &initial_signals_attr, &html)) } Err(e) => Html(document_shell( @@ -210,52 +213,27 @@ async fn initial_about_load(State(state): State) -> impl IntoRespon /// `POST /render` — Signal-triggered rerender. /// -/// DataStar sends all non-`_` signals as JSON body when any signal changes -/// (triggered by `data-on-signal-patch` + `@post('/render')`). -/// -/// Returns `Content-Type: text/event-stream` with a single -/// `datastar-patch-elements` event containing the full page HTML. +/// DataStar sends all non-`_` signals as JSON body on every signal patch. +/// Returns `text/html` with the full `
` wrapper — DataStar +/// morphs it into the existing DOM by matching the element id. async fn render_handler( State(state): State, Json(body): Json, ) -> impl IntoResponse { let signals = SignalStore::from_json(body); + let (conn_id, tree) = extract_conn_and_tree(&signals); - // Extract connection id - let conn_id: Uuid = signals - .get("connId") - .and_then(|v| v.as_str()) - .and_then(|s| s.parse().ok()) - .unwrap_or_else(Uuid::new_v4); - - // Extract component tree - let tree: ComponentTree = signals - .get("tree") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or_default(); - - match render_page(&state.db, conn_id, signals, tree).await { + match render_page(&state.db, conn_id, signals.clone(), tree).await { Ok((html, keys, signals_json)) => { - // Update connection state - state.connections.update_signals(&conn_id, signals_json); + state.connections.update_signals(&conn_id, signals_json.clone()); state.subscriptions.update(conn_id, keys); - - let sse_body = format_patch_elements(&html); - ( - [(header::CONTENT_TYPE, "text/event-stream")], - sse_body, - ) - .into_response() - } - Err(e) => { - let error_html = format!(r#"

Render error: {}

"#, e); - let sse_body = format_patch_elements(&error_html); - ( - [(header::CONTENT_TYPE, "text/event-stream")], - sse_body, - ) - .into_response() + Html(app_wrapper(conn_id, &signals_json, &html)) } + Err(e) => Html(app_wrapper( + conn_id, + &signals.to_json_string(), + &format!("

Render error: {}

", e), + )), } } @@ -265,73 +243,43 @@ struct ActionIdParams { id: i64, } -/// Helper: extract common fields from signal body, run a mutation, then rerender. +/// Helper: run a mutation, push the rerendered page down the SSE pipe, return 200. /// -/// Returns an SSE response with the rerendered page. -async fn action_then_render( +/// Actions do not return a body — the result comes down the SSE pipe as a +/// `datastar-patch-elements` event. +async fn action_then_push( state: &AppState, - body: serde_json::Value, + signals: SignalStore, mutation: impl std::future::Future> + Send, - patch_signals: Option<&str>, -) -> impl IntoResponse { - let signals = SignalStore::from_json(body); +) -> StatusCode { + let (conn_id, tree) = extract_conn_and_tree(&signals); - let conn_id: Uuid = signals - .get("connId") - .and_then(|v| v.as_str()) - .and_then(|s| s.parse().ok()) - .unwrap_or_else(Uuid::new_v4); - - let tree: ComponentTree = signals - .get("tree") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or_default(); - - // Run the mutation if let Err(e) = mutation.await { - let error_html = format!(r#"

Action error: {}

"#, e); - return ( - [(header::CONTENT_TYPE, "text/event-stream")], - format_patch_elements(&error_html), - ) - .into_response(); + eprintln!("Action error for {}: {}", conn_id, e); + return StatusCode::INTERNAL_SERVER_ERROR; } - // Rerender match render_page(&state.db, conn_id, signals, tree).await { Ok((html, keys, signals_json)) => { - state.connections.update_signals(&conn_id, signals_json); + state.connections.update_signals(&conn_id, signals_json.clone()); state.subscriptions.update(conn_id, keys); - let mut sse_body = String::new(); - // Optionally patch signals (e.g. clear the input) - if let Some(ps) = patch_signals { - sse_body.push_str(&format_patch_signals(ps)); + if let Some(sender) = state.connections.get_sender(&conn_id) { + let event = format_patch_elements(&app_wrapper(conn_id, &signals_json, &html)); + // Non-blocking: if the pipe is closed the cleanup task handles it + let _ = sender.try_send(event); } - sse_body.push_str(&format_patch_elements(&html)); - ( - [(header::CONTENT_TYPE, "text/event-stream")], - sse_body, - ) - .into_response() + StatusCode::OK } Err(e) => { - let error_html = format!(r#"

Render error: {}

"#, e); - ( - [(header::CONTENT_TYPE, "text/event-stream")], - format_patch_elements(&error_html), - ) - .into_response() + eprintln!("Render error for {}: {}", conn_id, e); + StatusCode::INTERNAL_SERVER_ERROR } } } -/// `POST /action/add_todo` — Create a new todo, then rerender. -/// -/// Reads the `newTodo` signal from the POST body, inserts it, and sends -/// back a `datastar-patch-signals` event to clear the input followed by -/// a `datastar-patch-elements` event with the rerendered page. +/// `POST /action/add_todo` — Insert a new todo, push rerender down SSE pipe. async fn action_add_todo( State(state): State, Json(body): Json, @@ -343,51 +291,54 @@ async fn action_add_todo( .trim() .to_string(); + let signals = SignalStore::from_json(body); let db = state.db.clone(); - action_then_render( + + action_then_push( &state, - body, + signals, async move { if !title.is_empty() { crate::queries::create_todo(&db, &title).await?; } Ok(()) }, - Some("{newTodo: ''}"), // Clear the input after adding ) .await } -/// `POST /action/toggle_todo?id=` — Toggle a todo's completed status. +/// `POST /action/toggle_todo?id=` — Toggle completion, push rerender. async fn action_toggle_todo( State(state): State, AxumQuery(params): AxumQuery, Json(body): Json, ) -> impl IntoResponse { + let signals = SignalStore::from_json(body); let db = state.db.clone(); let id = params.id; - action_then_render( + + action_then_push( &state, - body, + signals, async move { crate::queries::toggle_todo(&db, id).await }, - None, ) .await } -/// `POST /action/delete_todo?id=` — Delete a todo. +/// `POST /action/delete_todo?id=` — Delete a todo, push rerender. async fn action_delete_todo( State(state): State, AxumQuery(params): AxumQuery, Json(body): Json, ) -> impl IntoResponse { + let signals = SignalStore::from_json(body); let db = state.db.clone(); let id = params.id; - action_then_render( + + action_then_push( &state, - body, + signals, async move { crate::queries::delete_todo(&db, id).await }, - None, ) .await -} +} \ No newline at end of file diff --git a/examples/todo/todo_list.rs b/examples/todo/todo_list.rs index 82f2342..13fee11 100644 --- a/examples/todo/todo_list.rs +++ b/examples/todo/todo_list.rs @@ -11,7 +11,7 @@ use maud::{html, Markup}; /// - `cx.run()` for data fetching /// - `cx.signal()` with `global()` scoping + `Signal::val` in expressions /// - `cx.signal()` with `mangle().client_only()` scoping (in todo_item) -/// - DataStar `data-bind`, `data-on-click`, `data-attr` attributes +/// - DataStar `data-bind`, `data-on:click`, `data-attr` attributes /// - `@post()` actions for mutations pub async fn todo_list(cx: &mut RenderContext) -> anyhow::Result { // Fetch all todos @@ -34,7 +34,7 @@ pub async fn todo_list(cx: &mut RenderContext) -> anyhow::Result { span.nav-current { "Todos" } " | " button.nav-link - data-on-click="$tree = {page: 'About'}; @post('/render')" { + data-on:click="$tree = {page: 'About'};" { "About" } } @@ -51,7 +51,7 @@ pub async fn todo_list(cx: &mut RenderContext) -> anyhow::Result { data-bind="newTodo" ; button - data-on-click="@post('/action/add_todo')" + data-on:click="@post('/action/add_todo')" data-attr=(format!("{{disabled: {}.trim() === ''}}", new_todo.val)) { "Add" } @@ -104,7 +104,7 @@ fn todo_item(cx: &mut RenderContext, todo: &Todo) -> Markup { input type="checkbox" checked[todo.completed] - data-on-click=(format!("@post('/action/toggle_todo?id={}')", todo.id)) + data-on:click=(format!("@post('/action/toggle_todo?id={}')", todo.id)) ; // Title with strikethrough when completed (class set server-side) @@ -114,7 +114,7 @@ fn todo_item(cx: &mut RenderContext, todo: &Todo) -> Markup { // Delete button button.delete-btn - data-on-click=(format!("@post('/action/delete_todo?id={}')", todo.id)) + data-on:click=(format!("@post('/action/delete_todo?id={}')", todo.id)) { "×" }