Talking to the server
The browser calls an action, and the server answers with patches: new values for signals, or new HTML for a part of the page.
Actions
An Action<S, A> is a server endpoint for the page whose signals are S, receiving arguments of type A. Declare it as a const and use the same value on both sides. The page calls it with call(), or call_with(args) where args is an expression evaluated in the browser when the call runs, and the router serves it with RouterExt::action. A handler that needs several values takes a tuple, which the page passes as a tuple of expressions.
The handler receives the router state and the arguments, already deserialized, and returns anything that converts into Patches<S>. A request whose arguments do not deserialize gets a 400 before the handler runs.
use std::sync::Arc;
use std::sync::atomic::{AtomicI32, Ordering};
use axum::Router;
use hypersig::html::{button, form, input, label, p, span};
use hypersig::{Action, Page, Patches, Route, RouterExt, Signals};
use serde::{Deserialize, Serialize};
#[derive(Signals, Serialize, Deserialize)]
struct Profile {
name: String,
saving: bool,
error: String,
saved: String,
likes: i32,
}
hypersig::routes! {
fn pages(Router<Likes>);
const INDEX: Route = "/" => index;
}
const SAVE: Action<Profile, String> = Action::post("/save");
const LIKE: Action<Profile> = Action::post("/like");
#[derive(Clone, Default)]
struct Likes(Arc<AtomicI32>);
async fn index(likes: Likes, (): ()) -> Page<Profile> {
let name = Profile::name();
let saving = Profile::saving();
let count = Profile::likes();
let initial = Profile {
name: String::new(),
saving: false,
error: String::new(),
saved: String::new(),
likes: likes.0.load(Ordering::Relaxed),
};
Page::new("Profile", initial)
.child(
form()
.on_submit(
SAVE.call_with(name)
.indicator(saving)
.error(Profile::error()),
)
.child(label().text("Name ").child(input().bind(name)))
.child(
button()
.attr("type", "submit")
.attr_when("disabled", saving)
.text("Save"),
)
.child(p().bind_text(Profile::error()))
.child(p().bind_text(Profile::saved())),
)
.child(
button()
// The count goes up at once, and the request follows.
.on_click(count.set(count + 1).then(LIKE.call()))
.text("Like ")
.child(span().bind_text(count.to_text())),
)
}
async fn save(_: Likes, name: String) -> Patches<Profile> {
Profile::saved().patch(format!("Saved as {name}.")).into()
}
async fn like(likes: Likes, (): ()) -> Patches<Profile> {
let total = likes.0.fetch_add(1, Ordering::Relaxed) + 1;
Profile::likes().patch(total).into()
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
let app = pages(Router::new())
.hypersig_runtime()
.action(SAVE, save)
.action(LIKE, like)
.with_state(Likes::default());
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app).await
}Busy and failed calls
indicator(flag) sets a bool signal to true while the request waits for its response, which the example uses to disable the button. error(message) sets a String signal to the error text when the call fails, which is a network error or a response that is not a success, and to "" each time the call is sent, so the same failure twice changes the signal twice.
Optimistic updates
A statement before the call changes the page at once, and the request follows: count.set(count + 1).then(LIKE.call()). The handler's patch then sets the count the server stored. When the server answers with an error, the change stays in the browser. error can show the failure, but nothing rolls the value back.
Patches
Patches<S> is a batch the runtime applies in order. sig.patch(value) sets one signal, and adds more to the same event, and Patches::new().all_signals(&state) replaces all of them. Only a page-rooted signal has patch, so the server addresses fields by name and never by a list position.
use hypersig::{Patches, Signals};
use serde::{Deserialize, Serialize};
#[derive(Signals, Serialize, Deserialize)]
struct Inbox {
unread: i32,
status: String,
}
// One signal, as a handler's whole answer.
let one: Patches<Inbox> = Inbox::unread().patch(0).into();
// Several signals in one event.
let both: Patches<Inbox> = Inbox::unread()
.patch(3)
.and(Inbox::status().patch("3 new messages".to_owned()))
.into();
assert!(both.to_event_stream().starts_with("event: hs-signals"));Each patch is sent as one Server-Sent Event. to_event_stream returns the body for a server that does not use axum.
List patches
patch sends a list whole, so a chat that sends each message with patch sends every earlier one again. ListPatch sends only the items that change: patch_append and patch_prepend add items at either end of the list the browser holds, patch_upsert replaces the item with the same key or appends it, and patch_remove removes the items with the given keys. The key is an expression of the item, as in each_keyed, and the browser computes it for each item. As with patch, only a page-rooted Vec signal has these methods.
use hypersig::{Patches, Signals};
use serde::{Deserialize, Serialize};
#[derive(Signals, Serialize, Deserialize)]
struct Message {
id: i32,
body: String,
}
#[derive(Signals, Serialize, Deserialize)]
struct Room {
messages: Vec<Message>,
oldest: i32,
}
let messages = Room::messages();
let by_id = |message: hypersig::Item<Room, Message>| message.at(Message::id());
let incoming = Message { id: 42, body: "hello".to_owned() };
// One new message, as a stream's next event.
let new: Patches<Room> = messages.patch_append(&[incoming]).into();
// An older page goes above the messages on screen, which stay where they are.
let older = vec![Message { id: 30, body: "earlier".to_owned() }];
let page: Patches<Room> = Patches::new()
.list(messages.patch_prepend(&older))
.signals(Room::oldest().patch(30));
// An edit replaces the message with its id, and a deletion removes it.
let edited = Message { id: 42, body: "hello again".to_owned() };
let edit: Patches<Room> = messages
.patch_upsert(&[edited], by_id)
.and(messages.patch_remove(&[7], by_id))
.into();
assert!(new.to_event_stream().starts_with("event: hs-list"));Items added before an each_keyed list keep the items already on screen where they were: the runtime scrolls the nearest scrollable ancestor by the height of the new items. Chrome's scroll anchoring does the same once the list is scrolled, but not at the very top, which is where a list that loads older items is when it loads them. Sig::prepend does the same in the browser. A list drawn with each matches its elements by position, so its first element shows the first new item and nothing scrolls.
Fragments
A Fragment is a part of the page the server can render again on its own. It owns its element id, so the id in the first page and the id in a later patch cannot differ. fragment(&value) renders it in the page, and Patches::new().element(&value) sends it again. The runtime compares the HTML it receives with the element that has that id and rewrites only the parts that differ. A patch includes the CSS rules its elements use. The server renders a fragment sent again on its own, so it cannot compare it with the rest of the page. The browser does once the patch is applied, and writes a warning to the console when the patch adds an id or a node ref name that another element already carries.
use std::time::Duration;
use axum::Router;
use hypersig::html::{El, Event, input, li, ul};
use hypersig::{Action, Fragment, Page, Patches, Route, RouterExt, Signals, fragment};
use serde::{Deserialize, Serialize};
#[derive(Signals, Serialize, Deserialize)]
struct Search {
query: String,
}
const FRUITS: &[&str] = &[
"apple", "apricot", "banana", "cherry", "grape", "melon", "peach",
];
struct Results {
query: String,
}
impl Fragment for Results {
type Signals = Search;
const ID: &'static str = "results";
fn render(&self) -> El<Search> {
ul().children(
FRUITS
.iter()
.filter(|fruit| fruit.contains(self.query.as_str()))
.map(|fruit| li().text(*fruit)),
)
}
}
hypersig::routes! {
fn pages(Router<()>);
const INDEX: Route = "/" => index;
}
const SEARCH: Action<Search, String> = Action::post("/search");
async fn index((): (), (): ()) -> Page<Search> {
let query = Search::query();
Page::new(
"Search",
Search {
query: String::new(),
},
)
.child(
input()
.attr("aria-label", "Search fruit")
.bind(query)
.on_debounced(
Event::Input,
Duration::from_millis(200),
SEARCH.call_with(query),
),
)
.child(fragment(&Results {
query: String::new(),
}))
}
async fn search((): (), query: String) -> Patches<Search> {
Patches::new().element(&Results { query })
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
let app = pages(Router::new())
.hypersig_runtime()
.action(SEARCH, search);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app).await
}Streams
A StreamAction served with RouterExt::stream runs a handler that returns a stream of patches, on GET. The response stays open until the stream ends or the browser leaves the page. Call it from on_load to send changes made elsewhere, such as by other users.
use std::time::Duration;
use axum::Router;
use futures::{Stream, stream};
use hypersig::html::{p, span};
use hypersig::{Page, Patches, Route, RouterExt, Signals, StreamAction};
use serde::{Deserialize, Serialize};
#[derive(Signals, Serialize, Deserialize)]
struct Clock {
seconds: i32,
}
hypersig::routes! {
fn pages(Router<()>);
const INDEX: Route = "/" => index;
}
const TICKS: StreamAction<Clock> = StreamAction::new("/ticks");
async fn index((): (), (): ()) -> Page<Clock> {
Page::new("Clock", Clock { seconds: 0 }).child(
p().on_load(TICKS.call().reconnect())
.text("Connected for ")
.child(span().bind_text(Clock::seconds().to_text()))
.text(" s"),
)
}
fn ticks((): (), (): ()) -> impl Stream<Item = Patches<Clock>> {
stream::unfold(0, |seconds| async move {
tokio::time::sleep(Duration::from_secs(1)).await;
let next = seconds + 1;
Some((Clock::seconds().patch(next).into(), next))
})
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
let app = pages(Router::new()).hypersig_runtime().stream(TICKS, ticks);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app).await
}cargo test from site/examples/ in the repository.Calls that overlap
Each call applies its patches when they arrive, so the answer to an earlier, slower search can overwrite the answer to a later one. Call::latest aborts the unfinished earlier latest call to the same action, and the patches it has not received are dropped. Calls from different elements to one action share it, because the handler decides which signals they patch. The server may stop the aborted handler at an .await, so mark only reads. A call without latest is never aborted, and an indicator stays true until every call that sets it has its answer.
use std::time::Duration;
use hypersig::html::{Event, input};
use hypersig::{Action, Signals};
use serde::{Deserialize, Serialize};
#[derive(Signals, Serialize, Deserialize)]
struct Search {
query: String,
results: Vec<String>,
searching: bool,
}
const FIND: Action<Search, String> = Action::get("/find");
let query = Search::query();
// A newer search aborts the one still waiting, so its answer is the one shown.
let field = input().bind(query).on_debounced(
Event::Input,
Duration::from_millis(200),
FIND.call_with(query).indicator(Search::searching()).latest(),
);Streams that drop
StreamCall::reconnect opens a stream again when its connection fails, it breaks off, or the server answers with a 5xx status, as the clock above does. It waits 1 second, then twice as long each time up to 30 seconds. The server sends the event hs-end last once the handler's stream finishes, so a response a host closes at its time limit, which ends before hs-end, counts as broken off. A stream that ended with hs-end, a 4xx answer, and an element that has left the page stop it. Each attempt evaluates the arguments again, so a stream that takes a signal its patches keep current, such as the id of the last message, can send only what came after it. A stream whose arguments stay the same should start with the current values, and the page catches up with what it missed. Only a call to a StreamAction, which is always served on GET, has reconnect, because sending a POST again could repeat a change.
Parts shared across pages
A piece that sits on several pages, such as a sign-out button in an app's shell or a newsletter form in a footer, is a Part: its own signal struct, an element built by Part::render, and actions over that struct. A page whose struct has a field of the part's type embeds it with html::part(Shop::footer()), and the part writes its paths under that field. Each call the part makes names the field in a header, so the one handler registered for the part's action serves every page that embeds it, and the signals it patches and the fragments it renders again land under the field the call came from. Calling the part's action outside the part, or embedding the part at a field of a list item, is a compile error.
Call::on_error runs a statement with the error text when that one call fails, and Call::on_success runs one after its patches are applied. Both run where the call was made, and let_in_stmt names a value when the call is sent, so a page that adds several items before the server answers marks only the item whose save failed. The storefront example puts one newsletter part on two pages and saves a wish list this way.