A page's modules and controller objects set timers: a toast's dismiss countdown, a debounced
save, a poll, a backup limit on a request. Each part that sets a timer registers a
stop with Service, so that one call, Service.stop_all(),
stops them all. A test suite uses it to end its run as soon as its last test passes, instead of
waiting for the longest timer to fire.
Service holds no timers. Each module keeps its own timers and clears them in its
own stop, next to the code that sets them.
stop that clears it. A module registers once when it loads; an object
constructed once per page registers in its constructor.stop goes through the set.disconnectedCallback, and so does a controller the element owns and stops
there. Timers that live in the element's module, shared by every instance, belong to a
service the module registers.stop, which sends the
work first. A debounced save that stopping would drop is saved now, rather than
cleared from outside.delay_ms, it is stored the moment the change is
made and needs no stop at all (job
queue § Debouncing a save).| Member | Signature | Description |
|---|---|---|
Service.add |
(name, { stop }) => void |
Register a service. stop is required; a call without one throws a TypeError. name identifies the service in a failure report, and several instances may share one. |
Service.stop_all |
() => Promise<void> |
Stop every registered service, including any that register while the others stop. Every service is stopped even when one fails; the failures are then thrown together as an AggregateError. Services keep working afterwards and can be stopped again. |
Every registered stop is called, all at once and in no order. A
stop sends the work its service holds, then clears its timers and removes its
page listeners. Because nothing orders the calls, a stop must not rely on another
service still running.
import Service from '/ui/lib/service.mjs'
// A module that shows a banner for a few seconds. Each call sets a timer, so the module keeps
// a set of the ones still waiting.
const hiding = new Set()
Service.add('banner', {
stop: () => {
for (const timer of hiding) clearTimeout(timer)
hiding.clear()
},
})
export function show(text) {
render(text)
const timer = setTimeout(() => { hiding.delete(timer); hide() }, 4000)
hiding.add(timer)
}
// A settings panel that saves a moment after the last change, straight to the server. Its
// stop sends a change still waiting rather than drop it.
class Settings {
constructor(api) {
this._api = api
Service.add('settings', { stop: () => this.stop() })
}
change(values) {
this._values = values
clearTimeout(this._timer)
this._timer = setTimeout(() => { this._timer = null; this._send() }, 400)
}
stop() {
if (!this._timer) return
clearTimeout(this._timer)
this._timer = null
return this._send()
}
_send() { return this._api.patch('/settings', this._values) }
}
// A draft that must survive the tab closing: the job queue holds the delay, so the draft is
// stored at once and there is no timer, and no stop, here.
function edit_draft(queue, text) {
queue.cancel_group('draft')
queue.enqueue_fetch({ group_id: 'draft', method: 'PUT', path: '/draft', body: { text }, delay_ms: 3000 })
}
A timer is an ordinary Node timer under a test runner, and closing a simulated window does not
clear it, so one pending dismiss countdown keeps a finished test file running for seconds.
Call Service.stop_all() in the suite's teardown, before closing the window, so
every service can still reach the document while it stops.
Service does not listen to page events. A browser freezes a page's timers while
the page waits in the back/forward cache and resumes them when the reader returns, so most
services need nothing there. A service that must act when the page is hidden, such as sending
a pending save before the tab closes, adds its own pagehide and
pageshow listeners.