htmx
With htmx a page asks the server for HTML and puts it in place, without a script of its own. Spider's side of it is small: handlers answer with HTML as they always do, and a few helpers cover the places where an htmx request needs a different answer.
The code on this page is from Bookings, the example app in examples/bookings (source). It uses htmx 4. A project is on htmx 2 or on htmx 4; what differs is said where it matters.
Which htmx
spider new bookings --htmx=4
Without the flag a new project is on htmx 2. Either way spider install downloads the script to public/js (it is not kept in git), and the layout loads it:
<script defer src="/js/htmx4.min.js"></script>
<script defer src="/js/htmx4-sse.min.js"></script>
The second line is the SSE extension, for live pages. On htmx 2 the file is /js/htmx.min.js.
A link that also works without it
The day view filters its rooms by what they have. Each filter is an ordinary link to an ordinary address, and htmx is added on top:
<a class="ui-chip" href="{ filter.href }" aria-pressed="false"
hx-get="{ filter.href }" hx-target="#day" hx-select="#day" hx-swap="outerHTML" hx-push-url="true">{ filter.label }</a>
With htmx the click asks for the same address, takes #day out of the page that comes back (hx-select), puts it where the current #day is, and writes the address in the browser's bar. Without htmx it is a link.
The handler does not know the difference. It renders the whole page for both, so the address works when it is opened in a new tab, shared or reloaded, and there is one template to keep right. Rendering only the part is an optimization for later: see c.isHtmx() below.
A form
<form action="/bookings/create" method="post"
hx-post="/bookings/create" hx-target="#booking" hx-select="#booking" hx-swap="outerHTML">
...
<button type="submit" hx-disable="this">Book room</button>
</form>
hx-disable="this" keeps the button from being pressed twice while the request is on its way (htmx 2 calls it hx-disabled-elt).
The handler has two answers:
/// Makes the booking. Refused, the form comes back as it was typed, with
/// what is wrong beside the field it is about.
pub fn create(c: *spider.Ctx) !Response {
const sent = try c.parseForm(Form);
const form: Form = .{
.room = sent.room,
.date = sent.date,
.start = sent.start,
.end = sent.end,
.title = std.mem.trim(u8, sent.title, " \t\r\n"),
.note = std.mem.trim(u8, sent.note, " \t\r\n"),
};
var errors: Errors = .{};
if (form.title.len == 0) errors.title = "Give the booking a title.";
if (form.title.len > 80) errors.title = "The title can have up to 80 characters.";
if (form.note.len > 500) errors.note = "The note can have up to 500 characters.";
const start = grid.slotOf(form.start);
const end = grid.slotOf(form.end);
if (start == null or end == null) {
errors.time = "Pick a start and an end between 08:00 and 20:00.";
} else if (end.? <= start.?) {
errors.time = "The end has to be after the start.";
}
if (errors.any()) return formPage(c, form, errors, .unprocessable_entity);
const made = repository.create(c.arena, .{
.room_id = form.room,
.day = form.date,
.starts = form.start,
.ends = form.end,
.title = form.title,
.note = if (form.note.len > 0) form.note else null,
.booked_by = c.params.get("_auth_name") orelse "Someone",
.user_id = viewerId(c),
}) catch |err| switch (err) {
error.SlotTaken => {
errors.conflict = true;
errors.time = try conflictMessage(c, form);
return formPage(c, form, errors, .unprocessable_entity);
},
error.InvalidBooking => {
errors.time = "That day and time cannot be booked.";
return formPage(c, form, errors, .unprocessable_entity);
},
error.NoSuchRoom => {
errors.time = "Pick a room.";
return formPage(c, form, errors, .unprocessable_entity);
},
else => return err,
};
day_view.announce(c, made.day);
// A form htmx posted gets the address in a header, any other a redirect.
return c.htmxRedirect(try std.fmt.allocPrint(c.arena, "/bookings/{d}", .{made.id}));
}
Refused, the form page comes back with a 422 and what is wrong beside its field. htmx 4 swaps a 4xx like any other answer, so the form on screen is replaced by the one with the messages, and what was typed is still there because the handler rendered it back. htmx 2 does not swap an error status unless the page asks it to.
Made, the visitor goes to the booking's page, and that is the one place where an htmx request needs another answer. A redirect (303) is followed by the browser before htmx sees it: htmx would receive the booking's page as the content for #booking and stay on the form's address. c.htmxRedirect(url) answers what each caller needs: a request htmx made gets a 200 with the address in the HX-Redirect header, and htmx goes there; any other request gets the 303.
The test asks both ways:
test "booking: a form htmx posted is answered for htmx" {
const app = try app_test.running();
var id_buf: [20]u8 = undefined;
const cedar = try roomId(&id_buf, "Cedar");
var buf: [1024]u8 = undefined;
const cookie = try app_test.signUp(app, &buf, "Ana Ribeiro", "ana.htmx@example.com");
const ana = app.with(&.{ cookie, "HX-Request: true" });
// Made: the next address in a header, since the browser would follow a
// redirect by itself.
var made = try ana.postForm("/bookings/create", Booking{ .room = cedar, .date = "2032-05-31", .start = "10:00", .end = "11:00", .title = "Sprint demo" });
defer made.deinit();
try made.expectStatus(200);
try std.testing.expect(std.mem.startsWith(u8, made.header("HX-Redirect").?, "/bookings/"));
// Refused: the same 422 and the same page; htmx takes the form out of it.
var refused = try ana.postForm("/bookings/create", Booking{ .room = cedar, .date = "2032-05-31", .start = "10:30", .end = "11:30", .title = "Another" });
defer refused.deinit();
try refused.expectStatus(422);
try refused.expectContains("id=\"booking\"");
try refused.expectContains("Cedar is already held from 10:00 to 11:00.");
}
"HX-Request: true" is the header htmx sends with every request: with it in a test, the handler answers as it would to htmx.
A page that asks again
A held booking runs out after ten minutes, and its page shows the minutes left. It asks for itself every twenty seconds:
<main id="booking" hx-get="/bookings/{ booking.id }" hx-trigger="every 20s" hx-select="#booking" hx-swap="outerHTML">
The day view does the same when the server says something changed, instead of on a timer: hx-trigger="changed from:body", where changed is an event that arrives on a stream. Live pages shows both ends of it.
What the server can tell htmx
The handlers above answer htmx and a plain browser alike. When a handler has more to say to htmx, it says it in the response's headers. The code of this section is from examples/guide (source): a small server kept as tests, for what the example apps do not use.
Only the part
c.isHtmx() is true for a request htmx made. A handler can then send only what changes:
const task_list = "<ul id=\"tasks\"><li>Write the report</li></ul>";
/// The same address gives a whole page to a browser and only the list to
/// htmx, which already has the page around it.
fn tasks(c: *spider.Ctx) !spider.Response {
if (c.isHtmx()) return c.html(task_list, .{});
return c.html("<!DOCTYPE html><title>Tasks</title><h1>Tasks</h1>" ++ task_list, .{});
}
Two answers now live at one address, and a cache between the app and the visitor could give one for the other. spider.varyHtmx, a middleware, marks every HTML answer so that it does not:
const htmx = &.{"HX-Request: true"};
test "htmx: the same address gives the page to a browser and the part to htmx" {
const app = try spider.testing.start(run);
var page = try app.get("/tasks");
defer page.deinit();
try page.expectContains("<h1>Tasks</h1>");
var part = try app.with(htmx).get("/tasks");
defer part.deinit();
try part.expectNotContains("<h1>");
try part.expectContains("<ul id=\"tasks\">");
// Two answers at one address: a cache has to keep them apart.
try page.expectHeader("Vary", "HX-Request");
try part.expectHeader("Vary", "HX-Request");
}
c.isBoosted() is true for a navigation made through hx-boost.
Where the answer goes, and an event for the page
c.htmx(.{ ... }) builds headers for the last argument of c.html or c.view:
/// Answers with the new item and says, in headers, where it goes and what
/// the page should announce.
fn addTask(c: *spider.Ctx) !spider.Response {
return c.html("<li>Call the supplier</li>", .{
.status = .created,
.headers = try c.htmx(.{
// Wherever the form said the answer goes, it goes here instead.
.retarget = "#tasks",
.reswap = .beforeend,
// An event on the page, with data: a script or `hx-trigger` hears it.
.trigger = try c.hxEvent("notice", .{ .message = "Task added", .type = "success" }),
}),
});
}
test "htmx: an answer says where it goes and what the page should announce" {
const app = try spider.testing.start(run);
var res = try app.with(htmx).request(.{ .method = "POST", .target = "/tasks", .body = "" });
defer res.deinit();
try res.expectStatus(201);
try res.expectHeader("HX-Retarget", "#tasks");
try res.expectHeader("HX-Reswap", "beforeend");
try res.expectHeader("HX-Trigger", "{\"notice\":{\"message\":\"Task added\",\"type\":\"success\"}}");
try std.testing.expectEqualStrings("<li>Call the supplier</li>", res.body);
}
.retargetand.reswapput this answer somewhere else, or another way, than the element asked for..reselecttakes a part of it..triggerraises an event on the page.c.hxEvent(name, data)writes one that carries data; a plain name is an event without any..push_urland.replace_urlchange the address in the browser's bar,.redirectand.locationgo to another page,.refresh = truereloads this one..trigger_after_swapand.trigger_after_settleare for htmx 2 only: htmx 4 ignores those headers.
When a handler fails
spider.errorHandler answers an error according to who asked. For htmx it keeps the status, tells it to swap nothing, and raises an event with a message, so the page says what went wrong and the visitor keeps what was on screen:
/// Fails: the error handler answers, in the way the caller can show.
fn archiveTask(c: *spider.Ctx) !spider.Response {
_ = c;
return error.Forbidden;
}
test "htmx: an error keeps the page as it is and raises a notice" {
const app = try spider.testing.start(run);
var res = try app.with(htmx).request(.{ .method = "POST", .target = "/tasks/7/archive", .body = "" });
defer res.deinit();
try res.expectStatus(403);
// Nothing is swapped: what the visitor was looking at stays.
try res.expectHeader("HX-Reswap", "none");
try std.testing.expect(std.mem.startsWith(u8, res.header("HX-Trigger").?, "{\"spider:toast\":{\"message\":"));
// "warning" for what the caller did (4xx), "error" for what broke (5xx).
try std.testing.expect(std.mem.indexOf(u8, res.header("HX-Trigger").?, "\"type\":\"warning\"") != null);
}
The event is spider:toast (.toast_event changes the name) and carries message and type. Showing it is the page's part: a listener for the event that puts the message on screen.
htmx 2 and htmx 4
What changes for the code on this page:
- An error status (4xx, 5xx) is swapped by htmx 4 and not by htmx 2. On htmx 2 the form above needs the page to allow the 422.
hx-disableon htmx 4 ishx-disabled-elton htmx 2.- On htmx 4 an attribute no longer applies to the elements inside the one that has it. Where a parent sets
hx-targetfor its children, it says so:hx-target:inherited. - Streams:
hx-sse:connectand an event in the page on htmx 4;hx-ext="sse",sse-connectandsse-swapon htmx 2. c.htmxRedirect,c.isHtmx()andHX-Redirectwork the same on both.