Event pacing

A drag fires a value change on every pointer move, and a search on every keystroke sends requests that are stale before they return. Chameleon's text, search, email, telephone, and number fields, its sliders and dials, and its navigation bar can hold back the events they fire, so your code hears a change only as often as it can use one. Adaptive pacing goes further: it measures how fast your server answers and never asks faster.

The modes⁠

Each component that paces an event takes one of these modes. The value updates on every change; the mode decides only when the component tells your code.

Mode When your code hears Use it for
immediate On every change Work that costs nothing, such as updating a label
debounced Once the user pauses for the delay A search that should wait for the user to stop typing
throttled At most once per delay while changes continue A live preview at a steady rate
interaction-ended Once, when the drag or scroll ends Saving the user's final choice
adaptive As often as your async work keeps up A live preview or search against a server

Fields have no interaction-ended, because typing has no gesture with an end, and a field already fires change when the user commits a value. A field given interaction-ended, or any other value it doesn't know, uses its default, immediate.

Which components pace which event⁠

Component Event paced Mode property Delay property and default Default mode
Text, search, email, telephone, and number fields input input-event-mode input-rate-limit-delay, 300 ms immediate
Slider, range slider, dial, and continuous dial change change-mode rate-limit-delay, 150 ms interaction-ended
Navigation bar, for its scroll progress scroll-progress-change scroll-change-mode scroll-rate-limit-delay, 150 ms throttled

Each component paces only the event named in the table. A field's value changes on every keystroke, a slider or dial fires input on every movement, and the navigation bar's scrollProgress follows every frame. Text areas and password fields don't pace their events.

Pacing has no app-wide setting: set the mode on each component that needs one.

Adaptive pacing⁠

A fixed delay is a guess about your server. Guess low and a slow server falls behind with a queue of requests; guess high and a fast one leaves the user looking at an old result. Adaptive pacing replaces the guess with a measurement.

Give the component your async work as asyncSink: a function that returns a promise, such as a request to your server. The component then:

  • Makes one call at a time. It never starts a call while the last is still running, so requests can't pile up behind a slow server.
  • Works on the newest value. Changes made during a call wait for it to finish and then go in one follow-up, which reads the value as it is when it starts.
  • Measures each call. The component asks a fast server at most once per delay, so the delay sets the fastest rate rather than a fixed period. It never asks a slow server faster than the server answers, and it asks less often as a server slows under load.
  • Backs off after a failure. When the promise rejects or doesn't settle in time, the wait before the next call grows, and it shrinks again as calls succeed.
  • Gives up on a call after 30 seconds. One call that never finishes can't stop the component for good.

sink-duty-cycle sets the share of your async work's time one component may take, from 0.01 to 1. The default, 1, starts the next call as soon as the last finishes. At 0.25 the component leaves three quarters of the time idle: with rate-limit-delay of 160 or less, a call that took 40 milliseconds is followed by a 120-millisecond wait. A longer delay lengthens that wait.

On a field⁠

An adaptive field waits for the user to pause, so the first letter of a word never starts a search on its own. It waits for the longest of three things:

  • The field's input-rate-limit-delay
  • Slightly more than this user's usual gap between keystrokes, so a slow typist isn't interrupted mid-word
  • The time until your async work is ready for its next call

The typing and async-work waits never exceed two seconds, and the field always waits at least input-rate-limit-delay. Without an asyncSink, the field still adapts to the user's typing.

In every mode, not only adaptive, a field calls its asyncSink after it fires input, one call at a time, and sink-duty-cycle spaces those calls. input events that arrive while a call runs share one follow-up call.

On a slider, a dial, or the navigation bar⁠

The first change of a drag reaches your code at once, unless the last call is still running or its wait hasn't ended. After that, each change waits for the last call to finish, then for the pause the component sets from how long calls take. Without an asyncSink, the component has nothing to measure and paces as throttled does: once per rate-limit-delay, and never more than once a frame.

The navigation bar takes its async work as scrollAsyncSink and its share as scroll-sink-duty-cycle.

Async work in each framework⁠

In each framework, asyncSink takes a function that returns a promise or task. Read the component's value inside it, as an event listener would.

Web components⁠

const field = document.querySelector("cw-search-field");
field.inputEventMode = "adaptive";
field.asyncSink = async ({ abortSignal }) => {
  const response = await fetch(`/api/recipes?q=${encodeURIComponent(field.value)}`, { signal: abortSignal });
  showResults(await response.json());
};
content_copy

abortSignal aborts when the component gives up on the call: after 30 seconds, or when the component leaves the page. Pass it to fetch so the request stops too. A newer value never interrupts a running call; it waits and goes in the next one. React components receive abortSignal as web components do. Blazor's ValueChangeRequest doesn't carry it.

React⁠

import { useRef } from "react";
import { CrSlider } from "@materia-technologies-inc/chameleon-components-react";

export function Brightness() {
  const slider = useRef(null);
  return (
    <CrSlider
      ref={slider}
      ariaLabel="Brightness"
      changeMode="adaptive"
      rateLimitDelay={50}
      asyncSink={async () => {
        await fetch(`/api/lamp?level=${slider.current.value}`, { method: "PUT" });
      }}
    />
  );
}
content_copy

Blazor⁠

<CbSlider @bind-Value="level" aria-label="Brightness"
          ChangeMode="ChangeEventMode.Adaptive" RateLimitDelay="50"
          AsyncSink="SendLevelAsync" />

@code {
    double? level = 50;

    Task SendLevelAsync(ValueChangeRequest request) => Lamp.SetLevelAsync(level);
}
content_copy