xoong
Menu · Recipes
DocsExtendRecipes

Recipes

xoong covers the common path with attributes and stays small, so some things are left to you: polling, a wizard, a token refresh. None needs a plugin; each is a few lines on the public API. They were all run against the current packages, and the places where a detail was not exercised are said so.

Names used below: getClient() and configure() from @xoong/data, setFormat() from @xoong/data/view, router from @xoong/ui, setLocale() from @xoong/i18n. A form's model is the form element's own model (form.model.get / set / body / reset / validate / touch).

Lists and refreshing

Load more with a cursor

<xo-view> replaces its rows when a parameter changes, which is right for pages and wrong for "Load more". Keep the rows yourself and ask the client for the next cursor.

import { XoElement, define } from '@xoong/core';
import { getClient } from '@xoong/data';

class LoadMore extends XoElement {
  #rows = []; #next;
  connected() {
    this.innerHTML = '<ul></ul><button type="button">More</button>';
    this.querySelector('button').onclick = () => this.more();
    this.more();
  }
  async more() {
    const page = await getClient().load('listUsers', this.#next ? { cursor: this.#next } : {});
    this.#next = page.next;
    this.#rows.push(...page.items);
    this.querySelector('ul').replaceChildren(...this.#rows.map((r) => Object.assign(document.createElement('li'), { textContent: r.name })));
    this.querySelector('button').hidden = !this.#next;
  }
}
define('x-load-more', LoadMore);

Polling

There is no refresh interval attribute. A view has refresh(); call it on a timer, and not while the tab is hidden.

const view = document.querySelector('#orders');           // an <xo-view>
setInterval(() => { if (!document.hidden) view.refresh(); }, 30_000);

Refetch when the person comes back

import { getClient } from '@xoong/data';

for (const type of ['focus', 'online']) window.addEventListener(type, () => getClient().invalidateAll());

Pushed updates: WebSocket, and SSE with a header

For a server that pushes events (upsert, delete, resync), live on a view uses the browser's EventSource. For anything else, feed the same events to the store yourself.

const ws = new WebSocket('wss://example.org/events');
ws.onmessage = (message) => {
  const event = JSON.parse(message.data);
  if (event.type === 'resync') getClient().invalidateAll();       // lost track: refetch what is on screen
  else getClient().store.applyEvent(event);                       // upsert or delete of one record
};
import { connectStream, getClient } from '@xoong/data';
import { EventSourcePolyfill } from 'event-source-polyfill';       // the native EventSource cannot send headers

connectStream({ client: getClient(), url: '/events', EventSource: EventSourcePolyfill });

A record pushed with an id the store already knows updates every view that shows it. A new record does not appear in a list that is already loaded; ask the list to refetch (invalidateAll() or a resync event).

Errors

Handle a failed call on your own screen

By default a failed action or read reaches the person through <xo-toast>. To handle it yourself, listen for xo-error: it bubbles from the view, button or form that made the call, and preventDefault() says "this one is mine" so the toast stays quiet. view.error is the latest read error; configure({ onError }) is the one global handler and runs first.

const view = document.querySelector('#orders');                  // an <xo-view>

view.addEventListener('xo-error', (event) => {
  event.preventDefault();                                        // no toast for this one
  const { status, code, detail, fields } = event.detail;         // a XoongError
  if (status === 403) showUpgradeBanner();
  else showInlineError(detail);
});

view.error;                                                       // the XoongError of the latest read, or null

A page can also listen once for everything: document.addEventListener('xo-error', report) sends every failure to your error tracker, whether or not it was prevented.

Sign-in

Refresh the token and retry once

Wrap fetch: on a 401, refresh and send the request again; give up to onUnauthorized. If several requests fail together, share one refresh promise (not exercised here).

import { configure } from '@xoong/data';
import { router } from '@xoong/ui';

configure({
  api, baseUrl: '/api',
  headers: () => ({ authorization: `Bearer ${token}` }),
  fetch: async (url, init) => {
    let response = await fetch(url, init);
    if (response.status === 401 && (await refresh().then(() => true, () => false))) {
      response = await fetch(url, { ...init, headers: { ...init.headers, authorization: `Bearer ${token}` } });
    }
    return response;
  },
  onUnauthorized: () => router.navigate('/login'),
});

Keep pages behind a sign-in

Mark the routes in markup, and let one guard decide. A guard returns true, false, or a path to go to.

<template xo-route="/billing" layout="shell" title="Billing" data-auth>...</template>
router.guard((to) => (to.route?.template.hasAttribute('data-auth') && !signedIn() ? '/login' : true));

Sign out

The client keeps what it fetched, so after a sign-out in the same tab a view that is mounted again would show the previous person's data at once. getClient().reset() aborts the requests in flight and drops every cached query and record; views that are mounted fetch again. Call it when the person signs out, or leave the page, which also starts with an empty cache.

import { getClient } from '@xoong/data';
import { router } from '@xoong/ui';

async function signOut() {
  await fetch('/api/logout', { method: 'POST' });
  getClient().reset();                       // nothing of this person stays in memory
  router.navigate('/login');
}

Files

Upload progress

The generated form sends files with fetch, which cannot report progress. For a big upload send the request yourself and drive the file control's progress.

const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/files');
xhr.upload.onprogress = (event) => { fileControl.progress = event.loaded / event.total; };
xhr.onloadend = () => { fileControl.progress = null; };
xhr.send(formData);

Download a CSV or PDF

Views and actions expect JSON, so a file is not one of their operations. With cookie authentication a plain link is enough; the router leaves download links alone.

<a href="/api/export.csv" download>Export CSV</a>

With a header-based token, fetch it and hand the browser a blob.

const response = await fetch('/api/export.csv', { headers: authHeaders() });
const link = Object.assign(document.createElement('a'), { href: URL.createObjectURL(await response.blob()), download: 'export.csv' });
document.body.append(link);
link.click();
link.remove();

Forms

A wizard

One form, several steps: show one group of fields at a time and validate only that group before moving on.

const STEPS = [['name', 'email'], ['plan'], ['card.number']];     // the field paths of each step
async function next(form, i) {
  const errors = await form.model.validate();
  STEPS[i].forEach((path) => form.model.touch(path));             // show the messages of this step
  if (!STEPS[i].some((path) => errors[path])) show(i + 1);
}

Autosave a draft

form.addEventListener('input', () => { localStorage.draft = JSON.stringify(form.model.body()); });

// on load
if (localStorage.draft) for (const [path, value] of Object.entries(JSON.parse(localStorage.draft))) form.model.set(path, value);

Reset a form in a dialog when it closes

form.closest('xo-dialog').dialog.addEventListener('close', () => form.model.reset());

Leave out fields that do not apply, or send only what changed

A field hidden with xo-if is still part of the draft and is sent. serialize shapes the body just before the request.

// drop a field that is hidden for this kind
form.serialize = (draft, body) => (body.kind === 'company' ? body : (({ vat, ...rest }) => rest)(body));

// PATCH: only the top-level fields the person changed
form.serialize = (draft, body) => Object.fromEntries(Object.entries(body).filter(([key]) => form.model.dirtyAt[key]));

Field errors from other backends

The form reads field errors from RFC 9457 problem details, FastAPI and { error: { fields } }. If your backend answers another shape, rewrite it in the fetch wrapper so the message lands on the input.

configure({ api, baseUrl: '/api', fetch: async (url, init) => {
  const response = await fetch(url, init);
  if (response.status !== 422) return response;
  const body = await response.clone().json();                      // { message, errors: { email: ['has already been taken'] } }
  const errors = Object.entries(body.errors ?? {}).flatMap(([field, messages]) => [].concat(messages).map((detail) => ({ pointer: '/' + field, detail })));
  return new Response(JSON.stringify({ title: body.message ?? 'Invalid', errors }), { status: 422, headers: { 'content-type': 'application/problem+json' } });
} });

Routing

A 404 inside a section

A route is matched exactly, so /admin/nope falls to the page's * route outside the admin layout. Send unknown paths under a prefix to a real not-found route inside it.

router.guard((to) => (to.route?.pattern === '*' && to.path.startsWith('/admin/') ? '/admin/not-found' : true));
// and <template xo-route="/admin/not-found" layout="admin">...</template>

Any depth: a splat route

Use the * route for a wiki-like tree and read the path yourself: router.current.path.

Hash routing

Not offered: routes are real paths. Make the host answer every path with the app's HTML (xoong dev --spa does it while you work; on a static host copy index.html to 404.html, or use the host's rewrite rule).

Language, formats and time

import { setFormat } from '@xoong/data/view';

setFormat('currency', (value, locale) => new Intl.NumberFormat(locale, { style: 'currency', currency: 'USD' }).format(value));
setFormat('epoch', (value) => new Date(value * 1000).toISOString());       // {created | epoch}

Data that depends on the language

configure({ api, baseUrl: '/api', headers: () => ({ 'accept-language': document.documentElement.lang }) });

await setLocale('vi');
getClient().invalidateAll();                                      // fetch again in the new language

Another sort convention

sortable speaks sort=field and sort=-field. For orderBy and dir, or sort=name:desc, use xo-param buttons.

<button xo-param="orderBy=name">Name</button>
<button xo-param="sort=name:desc">Name, descending</button>

A time that ticks

A filter such as {created | relative} is computed once. For "3 minutes ago" that keeps counting, write a small element that repaints on a timer.

import { XoElement, define } from '@xoong/core';

class TimeAgo extends XoElement {
  static props = { at: String };
  connected() {
    const paint = () => { this.textContent = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }).format(Math.round((new Date(this.at) - Date.now()) / 60000), 'minute'); };
    this.effect(paint);                                           // read `this.at` inside the effect: the attribute is bound after the element connects
    this.timer = setInterval(paint, 30_000);
  }
  disconnected() { clearInterval(this.timer); }
}
define('x-time-ago', TimeAgo);

Two backends

configure({ api, baseUrl: '', fetch: (url, init) => fetch((String(url).startsWith('/auth/') ? 'https://auth.example.com' : 'https://api.example.com') + url, init) });

Deployment

Caching

Built files keep their names (nothing is hashed), so a browser can keep an old module next to a new page. Serve the built files with Cache-Control: no-cache and an ETag, which makes the browser ask each time and download only what changed. A tab that stays open during a deploy can still mix old and new modules until it is reloaded.

Assets the build cannot see, and a service worker

xoong build follows what pages and scripts reference. A file named only in a string (register('/sw.js'), icons in a manifest) is added with --include, and under --base it needs its prefix: build such URLs from document.baseURI.

npx xoong build --out dist --base /admin --include sw.js --include data --include img
navigator.serviceWorker.register(new URL('sw.js', document.baseURI));

Known limits

These are found, understood and not fixed: each one is a trade-off or waits for a real need. The workaround is on the same line, so nobody has to find out the hard way.

LimitWhat happensWhat to do
A pager needs total<xo-table page-size> and <xo-pager> on an API that answers items and has_more draw no Previous or Next.Add total to the response, or page with xo-param buttons and a cursor (see Load more).
Retries are for outagesA GET is retried on 502, 503, 504 and network errors; 408 and 429 are not, and Retry-After is ignored.Wrap fetch (as in Token refresh): wait for Retry-After and send again.
A new record does not join a loaded listA pushed record that already has an id on screen updates in place; a new one is stored but the list stays as it was.Send a resync event, or call getClient().invalidateAll().
Object query parametersAn object value (filter: { status: 'open' }) is written as [object Object] and shares one cache entry with every other object value. A bracketed name is fine: xo-param="filter[status]=open" sends filter[status]=open.Use bracketed names (or flat parameters) instead of an object value.
Braces in a viewA {...} is a binding only when it holds a path (with an optional | format): JSON, CSS and regular expressions in a view stay as written. {{name}} is not xoong syntax.Write \{name} for a literal brace pair that looks like a path; a key with a space or a colon needs a quoted key such as {['a b']}.
Parameter names with a dashp-page-size becomes the query name pageSize; an API that wants page-size gets nothing.Name the parameter limit or pageSize, or set view.params = { 'page-size': 20 } from script.
Duplicate ids in one listRows are keyed by the record's id, so [A, B, A] draws two rows.Give each row an id (a server id, or a composite one).
Rows without an idThey are matched by position: text typed into an input of a row stays at its position when a row above is removed.Give the rows an id.
Hidden and disabled form fieldsA field that xo-if hides, or that is disabled, is still in the draft and is sent: the draft cannot tell "hidden for now" from "not applicable".Shape the body with form.serialize (see Forms).
Copying a built comboboxcloneNode(true) of a drawn <xo-combobox> has no options: they were read from the <option> children and removed.Clone before it is drawn (from a <template>), or set options on the copy.
--base and links<a href="/about"> is left as written (the router handles links; relative links keep working).Use relative links in static pages, or base-links on the router.
No live reloadxoong dev serves the files fresh but does not reload the page.Reload by hand.