Requests from markup: cyclewire/request
Links, forms and buttons that ask your server for HTML and put it into the page, with no JavaScript of your own:
<a href="/products?page=2" cw-action="request" cw-prevent cw-target="#grid" cw-select="#grid > *" cw-swap="append">More products</a>cyclewire/request is an action. Register it once, under any name; this page calls it
request:
import { start } from 'cyclewire';
start({ actions: { request: () => import('cyclewire/request') } });Without a bundler, give its file instead:
{ request: 'https://cdn.jsdelivr.net/npm/cyclewire@1/dist/request.min.js' }.
Being an action has three consequences:
- It costs nothing until it is used. Its 3.7 kB (brotli) arrive when someone reaches
for an element that uses it, like any other action’s code. They include
morph and what applies
<cw-stream>messages, so the first answer that needs them does not wait for more code. - Everything else in CycleWire applies:
cw-on-<event>for the event,cw-debounce,cw-trigger,cw-concurrency,cw-pendingwhile a request is on its way,cw:run/cw:done/cw:error,cyclewire/earlyfor taps before CycleWire starts. - Markup reaches it only because you registered it, as with every action (see Security).
The benchmark builds its store page
this way too, as the cyclewire--request variant: on the phone profile it answers the
filter, the search and the quick view sooner than the htmx app, with less JavaScript.
Examples
Section titled “Examples”A form that gives way to the server’s answer. The form works without JavaScript too:
<form action="/newsletter" method="post" cw-action="request" cw-swap="outer"> <input type="email" name="email" required> <button>Subscribe</button></form>Search as you type. Input runs in restart mode, so each new request aborts the one
before:
<input type="search" name="q" cw-on-input="request" cw-get="/search" cw-debounce="150" cw-target="#results"><div id="results"></div>A button that deletes its row:
<tr> <td>Lamp</td> <td><button cw-action="request" cw-delete="/cart/items/42" cw-target="closest tr" cw-swap="remove">Remove</button></td></tr>A section that loads itself as it scrolls into view:
<section cw-action="request" cw-trigger="visible" cw-get="/reviews/42" cw-swap="outer">Loading reviews…</section>A cart refreshed in place, keeping what the visitor typed into it:
<button cw-action="request" cw-post="/cart/items?sku=lamp" cw-target="#cart" cw-swap="morph">Add to cart</button>Attributes
Section titled “Attributes”| Attribute | Default | |
|---|---|---|
cw-get, cw-post, cw-put, cw-patch, cw-delete |
The element’s own | The method, and the URL. Empty, or absent, it is the element’s own: a submit button’s formaction, a link’s href, a form’s action and method |
cw-target |
The element | Where the answer goes: a selector, looked up in the element’s document or shadow root, or closest <selector> |
cw-swap |
inner |
How: inner, outer, before, after, prepend, append, morph, remove or none. Add transition to run it in a View Transition: cw-swap="inner transition" |
cw-select |
Everything | The elements of the answer to use, by selector: a page answered in full can serve as a partial |
morph changes the target’s children to the answer’s, keeping focus and typed input
(morph). If the answer is one element with the target’s id, the target
itself is morphed onto it.
cw-prevent stops a link from being followed as well. Forms and submit buttons need
nothing: CycleWire takes their submit in any case.
The server helpers write these attributes too, and escape the URL:
cw('request', null, { get: '/products?page=2', target: '#grid', swap: 'append' }).
What is sent
Section titled “What is sent”- A form sends its fields, and the button that submitted it.
- An element inside a form sends that form’s fields.
- A field on its own sends its name and value.
- GET puts them in the query string. Other methods send them as the form would:
multipart/form-dataif itsenctypesays so, URL-encoded otherwise. - The CSRF token of a
<meta name="csrf-token" content="…">goes in anX-CSRF-Tokenheader, which Rails and Laravel read, on every request but GET. In Django, send the form’s{% csrf_token %}field, or setCSRF_HEADER_NAME = 'HTTP_X_CSRF_TOKEN'.
What comes back
Section titled “What comes back”- 2xx: the answer goes in. 204 No Content changes nothing, except that
cw-swap="remove"still removes its target. - 422: a form sent back with its errors, shown like any answer.
- Anything else changes nothing. The action fails with an error, reported by
cw:errorandonErrorlike any action’s. - Redirects are followed, as far as your own origin.
- The newest request for a target wins. A request still on its way to a target stops
when another one sets out for it, from any element: a category tapped while a search is
loading shows the category’s products, never the search’s late answer. The request that
stopped ends quietly, without
cw:error. <cw-stream>messages at the top level of an answer are applied: each changes the element it names (stream), and the rest of the answer goes to the target. One answer can update the cart badge, the flash message and the list at once.- Scripts in an answer never run: it is parsed inertly, like everything
cyclewire/domparses.
In an action of your own
Section titled “In an action of your own”run is an ordinary handler, so an action can make the request and then do what markup
cannot declare. A quick view that opens its dialog once the product is in it:
<a href="/products/42" cw-action="quickview" cw-prevent cw-target="#quick-view">Quick view</a><dialog id="quick-view"></dialog>import { run as request } from 'cyclewire/request';
export async function run(context) { await request(context); // reads cw-target and the rest from the element, as always document.getElementById('quick-view').showModal();}Fetch it before the click
Section titled “Fetch it before the click”With the prefetch plugin, an empty cw-prefetch fetches the element’s own
URL (its cw-get, or a link’s href) when the pointer or focus reaches it, and the
request takes that response:
<a href="/products?page=2" cw-action="request" cw-prevent cw-prefetch cw-target="#grid" cw-select="#grid > *" cw-swap="append">More products</a>Only for URLs that are safe to fetch early: a GET that changes nothing.
On the server
Section titled “On the server”Nothing needs to change: answer with the whole page, and let cw-select pick the part to
use. To send less, answer with only that part. A request CycleWire makes is a fetch(),
which browsers mark, over HTTPS, with Sec-Fetch-Dest: empty, where a navigation says
document:
// Laravelreturn $request->header('Sec-Fetch-Dest') === 'empty' ? view('products._grid', compact('products')) : view('products.index', compact('products'));# Railsrender request.headers['Sec-Fetch-Dest'] == 'empty' ? 'products/_grid' : 'products/index'When one URL answers both ways, say so to caches with Vary: Sec-Fetch-Dest.
Security
Section titled “Security”- Your own origin only. A URL on another origin is refused before anything is sent, and so is a redirect that leads to one: markup never sends the page’s data to another site, nor puts another site’s HTML into the page.
- Markup reaches only what you registered. Registering the request action is what lets
markup make requests at all. Once it is registered, markup injected into the page can
make them too, to your own origin, as it can run any other registered action (see
script gadgets).
Keep user content inside
cw-ignore, where nothing binds. - Answers are HTML your server wrote. Escape what users wrote in them, as in any page.
Scripts in answers never run, and with Trusted Types they pass through the
cyclewirepolicy, as withhtml.raw().
From htmx
Section titled “From htmx”| htmx | CycleWire |
|---|---|
hx-get="/x" |
cw-action="request" cw-get="/x" |
hx-post, hx-put, hx-patch, hx-delete |
cw-post, cw-put, cw-patch, cw-delete |
hx-target="#x", hx-target="closest tr" |
cw-target="#x", cw-target="closest tr" |
hx-swap="innerHTML", outerHTML, beforebegin, afterend, afterbegin, beforeend, delete, none |
cw-swap="inner", outer, before, after, prepend, append, remove, none |
hx-select="#x" |
cw-select="#x" |
hx-trigger="keyup changed delay:150ms" |
cw-on-input="request" cw-debounce="150" |
hx-trigger="revealed", load |
cw-trigger="visible", load |
hx-swap-oob |
<cw-stream> messages in the answer |
hx-sync |
Built in for one target: the newest request for it wins. Per element, cw-concurrency |
hx-indicator |
[cw-pending] in CSS |
hx-boost |
Not built in: request actions on the links and forms you choose |