Actions, Forms and UI
<form action="?/add">
<input name="text">
<button>Add</button>
</form>
<button action="?/remove&id={todo.id}">Delete</button>
#[action]
fn add(#[validate(len = 1..=100)] text: String) {
/* ... */
}
#[action]
fn remove(id: u64) {
/* ... */
}
Actions
action="?/like"posts to thelikeaction and addsmethod="post"when missing.- A form that posts to
?/nameneeds#[action] fn namein the same page: the build says so when it is missing (a layout or component may post to the page that uses it, so only a page's own markup is checked). - A form with no
action(andmethod="post") posts todefault. <button action="?/remove&id={todo.id}">outside a form becomes its own<form method="post"><button formaction="...">. Works without JS.- Query parameters in an action URL are read like form fields (
idabove isid: u64). - A body with
.awaitmakes the actionasync(#[action]adds it); never writeasyncthere. - Cookies, sessions and sign-in: design-sessions.
Validation
- Parameter rules:
#[validate(...)]withlen,min,max,min_len,max_len,email. Same as aFromJsonfield. - Every parameter is read and checked first, so one 422 lists each failure by field (
wisp::rt::input::read), from a form or a JSON body. - A parameter may be a struct with
#[derive(FromJson)]orRest(fn default(post: Post)). Fields are read by name (text by the field's type, blank field = missing) or from JSON, and checked by their own#[validate](wisp::rt::input::whole). - A failure, or
return invalid("text", "..."), shows the page again as a 422.
On that 422 page:
- Each named
<input>,<textarea>,<select>of the posting form shows what was sent (wisp::rt::kept) instead of its own value, then<small class="problem">...</small>(wisp::rt::problem). - Own value forms:
value={post.title}, orvalue="text"(same node, position in the tag does not matter). A hole inside,value="a{b}", is a build error. Textarea content works;<select value={post.kind}>marks the matching optionselected. - Passwords and files show the problem but are never sent back. Checkboxes, radios, hidden inputs and component inputs are left alone.
{cx.problem("text")}places that field's<small>yourself (nothing when none); none is added then.- A GET has neither; use an
ifoncx's locals (none) so the page can still be baked.
Browser-side checks come from the same rules (wisp_build::rules::Native). A field that an action of the page reads gets, as static text, only attributes the server also checks:
| Attribute | Added when |
|---|---|
required | blank is refused (struct field, number, Email, Image, text whose rules refuse it) |
type="email" | email rule (WHATWG check, same as the server) |
minlength | min_len (UTF-16 units are never fewer than characters) |
pattern="[\s\S]{0,N}" | max length (maxlength would count an emoji twice) |
min / max | type="number" input |
A textarea gets only required (line breaks are sent as two characters). The server still checks everything.
Request Flow
- Same-origin check:
Origin, if present, must matchHost(else 403). Without it,Sec-Fetch-Siteother thansame-originornoneis 403. A client sending neither (curl) passes. - The action runs.
redirect("/...")is a 303; other errors go to the error page. Forwisp.js(requests carryx-wisp) a redirect is a 200 withx-wisp-locationand the script navigates itself (fetch would follow with the post's headers, and not at all to another site). - On success the page's
loadruns and renders as normal. Without JS the browser shows it. wisp.jsintercepts the submit, sends it withfetch, and morphs<body>in place (keyed byid), so focus, scroll and unrelated inputs survive.
wisp.js details:
- A redirect to the same path updates the URL with
history.pushState; elsewhere it loads that page. A non-HTML response (file, JSON) is shown as the browser would. - The submit button is disabled while the request is out and re-enabled before the morph. A second submit of the same form meanwhile (Enter twice) is dropped.
- Forms are read through attributes and
HTMLFormElement.prototype(a field namedactionorresethides the property). - Sent as the browser would:
multipart/form-dataforms as multipart with files, others urlencoded. - Forms with another target, and posts that fail on the network, are left to the browser.
- After each morph the document gets a
wisp:updateevent (for scripts setting up what the morph brought in). data-wisp-keepon an element leaves its children and attributes alone (a map, a rich text editor).- A post that redirects loads the page so its scripts run as on any load. A script a morph brings into the same page (inside an
{#if}) runs once, the first time it appears.
Forms and Files
cx.form()reads urlencoded and multipart bodies (enctype="multipart/form-data"for files). Text fields read the same either way.cx.form().file("photo")is the file of<input type="file" name="photo">(Nonewhen none):nameas sent,content_type,bytes.files("photo")is every file of amultipleinput.- File name and type are visitor input: the name is never a path, the type says nothing the bytes do not.
- Uploads are held in memory; a route taking large ones raises its own
BODY_LIMIT.
Images
avatar: Image (or Option<Image>, may be empty) is an action parameter.
- Max 2 MB (
wisp::MAX_SIZE) unless#[validate(max_size = 5 * MB)]. The build adds each action'smax_sizeto the page's body limit; noBODY_LIMITneeded. - The build gives the form
enctype="multipart/form-data"and the inputaccept="image/*". - Kind comes from the first bytes: PNG, JPEG, GIF, WebP or AVIF. Anything else (SVG included, it can carry script) or over
max_sizeshows the page again as a 422 with the problem by the field. - Cloning shares the bytes. In a saved table it is a
data:URL (its JSON). - As a response:
fn get(id: u64) -> Option<Image> { USERS.get(id)?.value.avatar }inavatars/[id=int]/+server.rssends bytes and type with an ETag (304 when matched),no-cache,nosniff. Any response with anetaganswers a matchingif-none-matchGET with 304.
Serving Files
// [...name]/+server.rs
async fn get(name: String) -> Result<Response> {
Response::file_in("uploads", &name).await
}
Response::file_in(dir, name)reads without blocking, typed by extension. A name reaching outside the directory (.., absolute path, drive) is a 404, like a missing file. The name may come straight from the URL.Response::download("report.csv", bytes)sends bytes the browser saves as that file name.
Built-In UI
Wisp draws a few things from one design system: Kinetrix's roles and values (dark, as the demo site), Wisp violet (#896ce0) as the one accent, only on what is interactive. One-pixel hairlines, two shadow steps, one type scale, one focus ring.
Styles live in crates/wisp/src/client/tokens.css (the one source of tokens), ui.css (buttons), error.css and dialog.css (dev only). All are --wisp-* tokens and .wisp-* classes, so they never touch app CSS.
| Piece | What it is |
|---|---|
| Error page | For apps without +error.wisp. Status and one line (status name, or the error's message when it says more), centered, dark tokens, styles inlined. No links or buttons; write a +error.wisp for those. Errors for endpoints and API clients are JSON (see api). |
Error page in wisp dev | Adds the status name, the request, what caused a 5xx and a link home. |
| Server errors in dev | Every answer has Server-Timing: total;dur=ms. A 5xx dev page holds its message (a panic says file:line) in <template id="wisp-server-error">, which wisp-dev.js opens in the dialog below. Debug builds only. |
| Build error dialog (dev) | Title, one sentence on where to look (Error in src/routes/+page.rs on line 7.), then the error in a code block with Copy. Lives in a shadow root off <html>, so app CSS and morphs cannot touch it. Closes when the next build succeeds. |
| Rebuild bar | A two-pixel accent line across the top once a rebuild has taken 200 ms. |
| Terminal | Every status line has a mark and words: ✓ done (green), ! needs a look (yellow), ✗ failed (red), › under way and ~ changed (dim). Violet is only for what can be typed. A failure is a sentence, then the reason or fix indented under it. |
Is This Page Useful?