Skip to content

18 Async sections

Roberto Ferranti edited this page Nov 3, 2026 · 4 revisions

Async sections

Four components hold content that may not exist yet: a tab panel, a wizard step, a dialog body, a drawer body. Rather than each inventing its own loading callback, they share one protocol. Entering a section fires an event; you answer it by putting content in the section; the component handles the waiting, the spinner, the failure and the staleness.

Nothing is required. A component whose sections are already filled by the server never uses the protocol at all, and an activation nobody listens for costs nothing.

The event

Entering a section fires section:requested on the component, and it bubbles, so a page-level listener works too. The target is always the component itself, never the section, which means e.target.localName tells you which family fired and e.target === e.currentTarget distinguishes a host's own sections from a nested component's.

Three types are fired for the same entry, so you can listen at whichever grain suits you.

type fired by example
section:requested always the generic catch-all
section:requested:#<index> tabs, wizard section:requested:#1
section:requested:<name> wizard sections carrying data-step section:requested:dettagli

The # marks the index form, so a positional listener can never be confused with a name. Dialogs and drawers have one body and no names, so they fire the generic type alone.

Answer through exactly one of the three. All three reach the same listener set, so registering on both the generic type and the name loads the section twice.

Answering

Listeners are registered with AsyncEvents.asyncOn, which is what makes the component able to await them. One added with addEventListener still runs, but the component does not wait for it: the section shows at once, without the loading ring, and a failed delivery paints nothing.

const http = wizard.component('http-client');

ful.AsyncEvents.asyncOn(wizard, 'section:requested:dettagli', async (e) => {
    if (!e.detail.first) {
        return;
    }
    const html = await http.get(`/steps/${e.detail.name}`).fetchText();
    e.detail.section.prepend(ftl.Fragments.fromHtml(html));
});

The detail carries four things:

field meaning
section the element to put content in
name the section's data-step, where it has one
index its zero-based position
first whether this is the section's first answered entry

Delivery is yours. The component never assigns the content: you append, prepend or replace inside detail.section however you like, which is what lets a delivery keep server-rendered markup that is already there.

What the component does around you

sequenceDiagram
    participant U as the user
    participant C as the component
    participant L as your listener
    U->>C: opens a tab, moves a step
    C->>L: section:requested
    Note over C: a frame later, the loading ring
    L-->>C: resolved
    Note over C: ring off, section painted
    C-->>U: the move resolves
Loading

The component awaits the union of every answer, and handles four things you would otherwise write yourself.

The spinner arrives a frame late. An answer that resolves synchronously never pends, so it never flashes a ring.

A failure paints, and rejects where someone is waiting. The section shows the problems the rejection carried. wizard.move() rejects too, since it has a caller; a tab click and a dialog open have none, so the chrome reports and that is the end of it.

A superseded delivery owns no chrome. Activate the same section again while the first answer is still in flight and the older one no longer touches the loading state, aria-busy or the error. Its content is another matter: whatever the older listener writes into the section still lands, so a listener that can be overtaken guards its own writes, by checking it is still the latest request before writing.

first is spent only by a listened activation. An activation nobody answered does not consume it, so a listener attached later still sees first: true on its first real entry.

While an answer pends the section carries the loading attribute and aria-busy="true", which the stylesheet draws as the ring, and a failure prepends a .ful-section-error with the alert role to the section, holding the problems' reasons one per line. Those are the hooks for css of your own.

Re-entering

refresh() fires the same event again, with first false from then on. The wizard takes a data-step name or the section itself, the tabs an index or the panel itself; a dialog and a drawer take no argument, having one body each.

await tabs.refresh(1);
await wizard.refresh('dettagli');
await drawer.refresh();

Its failures paint the section's problems and stop there. move() and update() are the calls that reject, so those are the ones to await when the outcome matters to your code.

AsyncEvents on its own

The same dispatch is available for events of your own, on the ful global.

asyncOn(el, type, fn) registers a listener the dispatcher can await and returns it, which is what asyncOff(el, type, listener) takes to remove it. fireAsync(el, evt, { mode }) fires one and waits, in one of three modes:

mode listeners resolves with
broadcast all of them, awaited together an array of their values
pipeline at most one that listener's value, which flows on
delegate exactly one, or it throws that listener's value

ful-form uses pipeline for submit:requested, which is how a local form's listener returns the response. The section protocol uses broadcast.

mixInto(...classes) installs fireAsync, asyncOn and asyncOff as methods on classes of your own, so a component can fire and register without reaching for the namespace.

Clone this wiki locally