Guide

Components

A component is a view with state that also runs in the browser:

component Counter(start: int = 0) {    state count = start                  // state: set once; assigning it updates what shows it    let double = count * 2               // recomputed on every render    <div>        <span>{count} (x2 = {double})</span>        <button onclick={() => { count += 1 }}>+</button>    </div>}route GET / = <html><body><Counter start={5}/></body></html>

Composition

Views and components nest like HTML: a capitalized tag renders another view or component, its attributes are the props, and what is between the tags arrives as the children parameter, or null for a tag with nothing between (since 0.7) (<Card title="x"/>), on the server as in the browser, so children == null tells the two apart (FEEDBACK28.md, Q-345), and quillo check warns on str(children), which is "null" then. A component is used by its tag: called as a function (let make = Dead, then make(), or given to map) it would show and never answer, so it is an error, and quillo check says so.

view Card(title: str, children) = <section.card><h2>{title}</h2>{children}</section>component Counter(label: str, start: int = 0, on_change = null) {    state n = start    <p>{label}: {n} <button onclick={() => { n += 1; on_change && on_change(n) }}>+</button></p>}component Panel(header, children) {          // `header` is a named slot: any HTML    state open = true    <div>        <div onclick={() => { open = !open }}>{header}</div>        {open && children}    </div>}component Dashboard() {    state total = 0    <Card title="Counters">        <Counter label="Apples" on_change={(v) => { total = v }}/>   // child to parent        <Counter label="Pears" start={3}/>        <Panel header={<b>Details</b>}><p>Last change: {total}</p></Panel>    </Card>}

Styling a component

A component or a view carries its own CSS. You write it with the component, and Quillo sends it to the browser in a stylesheet of its own: nothing is written into the page's HTML for it. The components on quillolang.org/components all work this way.

Write it at the start of the body, before the markup:

component Card(title: str) {    style css`      .title { color: darkred }      .title:hover { text-decoration: underline }    `    state open = false    <div.card @click={() => { open = !open }}><h2.title>{title}</h2>{open && <p>More about {title}</p>}</div>}route GET / = <html><head><title>Cards</title></head><body><Card title="Hello"/></body></html>

The page has a link to the sheet in its <head>, and each element the component writes carries the component's attribute:

<link rel="stylesheet" href="/_quillo/s/a1ce371646f1b209.css">...<h2 class="title" data-q-1df36318="true">Hello</h2>

/_quillo/s/a1ce371646f1b209.css holds the component's rules, made to need that attribute, so they style the card's .title and no other:

.title[data-q-1df36318] { color: darkred }.title[data-q-1df36318]:hover { text-decoration: underline }

Or keep the CSS in a file of its own, beside the program, and give style its text:

let card_css = read_file("card.css")     // read once, as the program startscomponent Card(title: str) {    style card_css    <div.card><h2.title>{title}</h2></div>}

Any text will do: a .css file read with read_file, or a css`...` kept in a variable. It is scoped and sent the same way.

Scoped, or as written. style scopes the rules to the component (above). style global sends them with their selectors as written, for a library whose class names are its own already. That is what the downloadable components do: each one is a .qwe file with its CSS in a variable and style global in its body, so copying the file brings the styles along.

// ui/collapsible.qwe, from the kit (shortened)let collapsible_css = css`.ui-collapsible { min-width: 0; color: var(--ui-fg); font: 15px/1.5 var(--ui-font); }.ui-collapsible > summary { display: flex; align-items: center; gap: 8px; cursor: pointer; }`view Collapsible(title: str, children) {    style global collapsible_css    <details class="ui-collapsible"><summary>{title}</summary><div>{children}</div></details>}

The kit's ui_css (its colours, fonts and the --ui-... variables the rules use) is the one stylesheet a page itself puts in: <style>{ui_css}</style> once, in the <head>.

What reaches the browser:

What a style cannot do. It is the same for every use of the component, so it cannot read the component's parameters or state. For a style that changes with them, set a class (<div class={{ open: open }}>) or a style={...} on the element. A page's own <style>, or a .css file under public/ linked from the page, is for what belongs to no component: the page's layout, its fonts.

More: :global(...), scoped @media and the other rules, a style for all of a file's components, style_with for PostCSS or Tailwind, and what quillo check says of a class nothing writes are in HTML literals.

JavaScript libraries

A component can hand an element to a library (a chart, a map, an editor) and call into it. Name the library on a line that starts with client (since 0.7), then call it as JavaScript does:

client "https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" use Chartclient /assets/leaflet.js use Lclient "https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js"    use confetticomponent Sales(points: list) {    state chart = null    <canvas onmount={(el) => {        chart = Chart.new(el, { type: "line", data: points })    // new Chart(el, ...)        () => chart.destroy()                   // when the canvas leaves the page    }}></canvas>    <button onclick={() => confetti({ particleCount: 120 })}>Celebrate</button>}component Where() {    <div class="map" onmount={(el) => { L.map(el).setView([14.6, 121.0], 12) }}></div>}route GET / = <html><body><Sales points={load_points()}/></body></html>

An ES module (import/export) gives names by exporting them, and module after the first word takes them. Nothing goes on window: what the module exports is kept in Quillo's runtime, where the page's scripts cannot reach or replace it, and is called as any library is:

client module "https://esm.sh/chart.js@4.4.1" use Chart, registerablespreload module "https://esm.sh/chart.js@4.4.1" use Chart as ChartJSclient module "https://esm.sh/dayjs@1.11.13" use default as dayjsclient module "https://esm.sh/three@0.170.0" use * as THREEclient /assets/helper.mjs use helpercomponent Scene() {    <canvas onmount={(el) => {        let scene = THREE.Scene.new()        scene.name = dayjs().format("YYYY")    }}></canvas>}

Without use, a page's own <script> tags work too, and js and js_new reach what they define by name:

component Sales(points: list) {    state chart = null    <canvas onmount={(el) => { chart = js_new("Chart", el, { type: "line", data: points }) }}></canvas>}route GET / = <html><head><script src="/assets/chart.js"></script></head>                    <body><Sales points={load_points()}/></body></html>

Single-page apps

url() gives { path, query, hash }: of the request on the server, and of the address bar in the browser, where what reads it follows it when it changes. A browser never sends the hash, so on the server it is "", and a component that shows something by the hash is made again in the browser. A query key given twice keeps the last. Once a component has called it, clicks on same-site links inside components change the page without reloading; Back and Forward work. navigate("/path") does the same from code; on a page where no component has called url(), it loads that page as a link would. (Links with a modifier key, target, download or data-reload work as usual.) A link or navigate("#spot") to a spot on the same page goes there as the browser does: scrolled into view, and CSS :target set. navigate("/page#spot") goes to the spot once the new page shows it, and stays at the top if it never does. A javascript: or vbscript: URL, however it is written ( JaVaScRiPt:), is an error, in the browser and on the server, as it is for redirect: navigate(next) after a sign-in, with ?next=javascript:..., would have run as the page (since 0.7).

component App() {    state cart = []                               // survives moving between pages    let path = url().path    <div>        <nav><a href="/">Home</a> <a href="/products">Products</a> Cart: {len(cart)}</nav>        {match split(path, "/") | filter((s) => s != "") {            [] => <Home/>            ["products"] => <ProductList/>                      // loads with state items = list_products()            ["products", id] => <ProductPage key={id} id={int(id)} on_add={(p) => { cart = cart + [p] }}/>            _ => <NotFound {path}/>        }}    </div>}route GET /*rest = <html><body><App/></body></html>       // every path renders on the server

And the pages it shows, with the server fns that give them their data:

let products = [{ id: 1, name: "Kettle", price: 30 }, { id: 2, name: "Teapot", price: 25 }]server fn list_products() -> list = products | map((p) => { id: p.id, name: p.name })server fn load_product(id: int) -> map? = products | find((p) => p.id == id)view Home() = <h1>Welcome</h1>view NotFound(path: str) = <h1>No page at {path}</h1>component ProductList() {    state items = list_products()    {items == null        ? <p>Loading…</p>        : <ul>{for p in items => <li key={p.id}><a href={`/products/${p.id}`}>{p.name}</a></li>}</ul>}}component ProductPage(id: int, on_add) {    state product = load_product(id)    {product == null        ? <p>Loading…</p>        : <div>              <h1>{product.name}</h1>              <button onclick={() => on_add(product)}>Add to cart, {product.price}</button>          </div>}}

Opened at /products/2, the server sends the Teapot's page whole. Clicking Products, then Kettle, changes the address and the page without loading one, each page showing Loading… until its call answers; Add to cart makes the cart 1, and Back keeps it.

Pages load their data with server fns in state initializers: on the server while rendering, in the browser when you navigate to them, where the state is null until the answer comes (show something for null). Give a page component a key from its URL (key={id}) so a new product starts with fresh state. navigate scrolls to the top; links outside the program's mount, and under /_quillo/, load as usual. A full page load starts every state afresh: keep what must survive one on the server, or in localStorage through js. See spa.qwe, which also answers 404 on the server for unknown paths.

Live data

live(load, ...args) makes component state follow the server: load (a server fn) runs again, and what shows the state follows it, whenever something it read changes, for anyone, not only when this page acts.

db.open("notes.db")db.exec("create table if not exists notes (id integer primary key, text text not null)")server fn load_notes() -> list = db.query("select id, text from notes order by id")server fn add_note(text: str) -> map = db.exec("insert into notes (text) values (?)", text)component Notes() {    state notes = live(load_notes)  // every open page sees new notes    state draft = ""    <div>        <ul>{for n in notes => <li key={n.id}>{n.text}</li>}</ul>        <form onsubmit={() => { add_note(draft); draft = "" }}>            <input value={draft} placeholder="A note"/>            <button>Add</button>        </form>    </div>}route GET / = <html><body><Notes/></body></html>

The form calls add_note, and nothing sets notes by hand: the insert writes notes, so every page whose loader read it runs load_notes again, this one included, and the list grows on each. A note added from anywhere else (another tab, another visitor, a POST /_quillo/rpc/add_note, a route) shows the same way.

Several servers

To run an app on several Quillo servers behind limer, let them join a discover pool with a broadcast path; changes made through one server then wake pages on all of them, with no message broker:

app.example.com {    reverse_proxy {        discover shop {            allow 10.0.0.0/8            broadcast /_quillo/announce        }    }}
export LIMER_ADMIN_SECRET=...        # limer's admin secretquillo run shop.qwe --port 8081 --register http://10.0.0.1:2019   # on each server

Server functions

server fn makes a function callable from the browser:

server fn add_note(text: str) -> list ! str with db {    if trim(text) == "" { return err("empty note") }    db.exec("insert into notes (text) values (?)", text)?    db.query("select * from notes")}component Notes(initial: list) {    state notes = initial    state draft = ""    state problem = null    <form onsubmit={() => {        let r = add_note(draft)                    // a request to the server        if is_err(r) { problem = r.error } else { notes = r; draft = ""; problem = null }    }}>        <ul>{for n in notes => <li key={n.id}>{n.text}</li>}</ul>        <input value={draft}/>        <button>Add</button>        {problem && <p class="problem">{problem}</p>}    </form>}route GET / with db = <html><body><Notes initial={db.query("select * from notes")}/></body></html>

The route gives the component the notes there are; pressing Add with the box empty shows empty note under it, and with text the list shows the new note and the box empties.

In component code the call is awaited automatically. A call that fails (the connection drops, the server errors) returns an error value, like http.* on the server, so let more = load_more() ?? [] carries on and can try again later. It works in event handlers and in state initializers (state todos = load_todos(): the server runs it while rendering, so the page arrives with the data). Calling one while rendering is an error.

The error value's text says what happened: server fn `x` could not be reached: ... when there was no answer, server fn `x` failed (403): ... when the server refused it (400 with the argument that did not fit, 403 from another site, 404 for no such function, 500 internal error in x, whose real message is on the server's stderr). A function that returns err(v) gives an error value with v. A reply 401 "Log in first" inside it gives only the status (failed (401)), since its body is not JSON: return err("Log in first"), or reply 401 { error: "Log in first" }, for the page to show the words. Nothing happens by itself on a 401 or a 500 (no redirect, no retry, except for idempotent calls); there is no loading flag either: set a state of your own around the call.

The latest call wins. A handler can run again before its call has answered (a letter typed in a search box, a second click), and answers come back in any order. An older call's answer never replaces a newer value: every set of a state claims it (a plain shown = "x", a field bound with bind:, a set from a call). A set that carries an answer the function waited for gives way when another run has set or claimed that state since. It carries the answer if its value holds the call or a variable bound from it, or if the answer decides it: it sits under an if, match, while or for on the answer (or &&, || or ?? used as one), or after a guard that leaves on it (return, ?). A run is one handler, effect, timer or frame callback, with the functions it calls; a handler fired by el.click() and each parallel function are runs of their own. So oninput={(e) => { shown = search(e.value) }} ends on the answer for what was typed last, however late the first one comes.

Every other set is made: a plain one, like saving = false after the wait, a fold, and a set keeping a state in step with one folded earlier in that same branch. A set that reads the state it sets is a fold (n += 1, busy -= 1, xs = xs + [x], s = s | push(x), s = f(s)), made on what the state holds then: three quick likes that each call save() and then likes += 1 count three, and two uploads that each do busy += 1, wait, and busy -= 1 end at 0.

In a fold with a wait in it, every part runs where it is written, and only the read of the state moves after the waits: n += count() adds to what n holds when the answer comes, and n = n + check()? + charge(1) makes no charge if check() stops. That is so for += and -=, and for = forms headed by the state (n + f(), a template, ...state in a list or map, set and push). Other shapes read the state first.

Only a set with an answer is ever dropped. Plain sets and folds are always made; from a run that lost the state they take nothing, so the newest answer still lands. So clear a state before the wait, not after it: in let r = search(q); wipe(); shown = r, an older run's wipe() can blank what the newest answer showed. Write the set in the function that waited: an answer handed to a helper as an argument counts as plain there.

In a function that waits, a hole of markup may wait too: let el = <b>{load()}</b>. It is worked out first, before the markup is made and before the holes around it, once, and does not follow what it read.

Slow data: defer. A state initializer runs while the server renders, so the page waits for its slowest one. defer lets a slow one follow the page instead:

component Product(id: int) {    state product = load_product(id)                  // in the first bytes    state reviews = defer(load_reviews(id))           // streamed when it is ready    <div>        <h1>{product.name}</h1>        {reviews == null ? <p>Loading reviews…</p> : <ReviewList reviews={reviews}/>}    </div>}

For data a page should not wait for at all (another site's API that may take seconds), render a placeholder and call the server fn from @mount.

There is no async or await to write: on the server a call simply waits (and other requests run meanwhile); in the browser the compiler awaits it, and every function that calls it. Calls run one after another. To run several at once, give them to parallel, which returns their results in order:

let [user, orders] = parallel(() => load_user(id), () => load_orders(id))

In the browser that is Promise.all. In a request on the server each function runs as a coroutine of its own on the event loop (outside a request, on a thread of its own), so their waits (database, http.*, sleep) overlap. Their computing does not: Quillo code runs one piece at a time, so parallel shortens waiting, not arithmetic. Inside a transaction they run one after another, since the others' SQL would not be part of it. A function that fails stops the others at their next step (a call, a loop's round, a builtin, a sleep, which ends at once), nested parallel too; what they did before it stays done, and the call fails with the failure that happened. Error values are results like any other and stop nothing. Inside the functions, set_cookie, set_session, end_session, session(), publish and a replica's read-your-writes work as they do outside, and what each sets goes into the answer in the functions' order, as if they had run one after another; one that reads who the visitor is makes the answer private to them.

Each call is a POST /_quillo/rpc/<name> with a JSON array of arguments (or, when an argument is a file, multipart: see Uploads), checked against the parameter types all the way in (a list<Role>'s items, a record's fields; a 400 otherwise), and records made as User(v) makes them (other fields left out); the answer is {"ok": value} or {"err": payload}. Only server fns can be called, only with content-type: application/json, and only from the program's own pages: a request a browser marks as coming from another site (Sec-Fetch-Site, or an Origin naming another host) gets a 403. Inside one, request is the current request. reply in a server fn becomes the HTTP response, so the usual middleware works:

server fn delete_all() = request | auth.required | (_) => { ... }

From hand-written JavaScript: await quillo.server.add_note("text"), which resolves to an object with .error rather than rejecting. window.quillo ({ server, components, rpc }) is there once a program with server fns has had its bundle loaded, which is when a component is reached; a script that runs as the page loads includes <script src="/_quillo/quillo.js"></script> first. A component cannot be mounted from a script: components start from the server's HTML, or inside another component.

A call that must not happen twice (a payment, a transfer) is wrapped in idempotent, in the server fn or a function it calls: the page then sends a key with each call, and sends the call again when its answer does not come back, and the server runs it once. Two clicks are one call: while a call runs, the same call with the same arguments shares its answer and sends nothing, and a call that got no answer keeps its key, so sending it again is safe. Any answer ends it, so the next payment, even of the same amount, is a new one. A call with other arguments is another call.

What the browser can reach

Components and server code share a file, but not what they can touch:

Thread
32Useful? Like it, or add to its thread below.

Thread 0 posts

On Components: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.

    Edit this page on GitHub · made from the user guide · questions on Discord or r/quillo