Services hooks
A service mixin binds one subscription per mount cycle, under the one method name the service owns. There is no hook option.
| Mixin | Hook | Props type |
|---|---|---|
withRaf | ticked | RafProps |
withScroll | scrolled | ScrollProps |
withResize | resized | ResizeProps |
withPointer | moved | PointerProps |
withDrag | dragged | DragProps |
withKey | keyed | KeyProps |
withInView | intersected | InViewProps |
withMutation | mutated | MutationProps |
withScrollProgress | scrolledInView | ScrollProgressProps |
Usage
import { Base, withScroll } from '@studiometa/js-toolkit';
class Header extends withScroll(Base) {
static config = { name: 'Header' };
scrolled({ y, directionY, isScrolling }) {
this.$el.classList.toggle('is-hidden', directionY > 0 && y > 100);
}
}Mixins stack, and their $services keys accumulate:
import { Base, withRaf, withResize } from '@studiometa/js-toolkit';
class Parallax extends withRaf(withResize(Base)) {
static config = { name: 'Parallax' };
#height = 0;
resized({ height }) {
this.#height = height;
}
ticked({ delta }) {
// …
}
}A mixin never occupies a lifecycle hook
mounted() and unmounted() belong to the component author, so nothing has to be chained. A class that mixes a service in and writes its own mounted() without super.mounted() still subscribes: the framework's own $mount()/$unmount() pair carries the subscription.
The consequences are worth knowing:
- The subscription starts once the whole of
mounted()has run — including animmediatefirst delivery, which therefore reaches a component that is fully set up. - It is released before
unmounted(), exactly where the mount cleanup used to release it. $unmount()releases unconditionally, so a manual subscription started outside a mount cycle is released too.
A userland mixin still chains
The rule is about what the mixin overrides, not about who wrote it. A mixin that puts its work in mounted() needs its subclasses to call super.mounted() — and the way not to need that is to override $mount().
Mixin options
interface ServiceMixinOptions<Target, Host = Base> {
target?: (instance: Host) => Target;
manual?: boolean;
immediate?: boolean;
}The options of a mixin are not the options of the service. target, manual and immediate describe the subscription. They are removed before the underlying use*() call and they are absent from its options type.
A service's own options are passed in the same object and forwarded:
class Slide extends withDrag(Base, { axis: 'x', inertia: false, immediate: true }) {}target
withResize(Base, { target: (instance) => instance.$refs.inner });A resolver that comes back with undefined or null reports service.missing-target and starts no subscription — because a renamed ref arrives here as nothing, and a service with a default target would otherwise observe the wrong thing and look like it worked.
A service whose own default target is nothing, like withRaf, is untouched.
The resolver is typed against the host as declared
A mixin is applied while the extends clause of its class is still being evaluated, so withDrag(Base, …) types its resolver against Base. A component reaching further names the shape it needs — (instance as Base & { readonly target: HTMLElement }).target — which is an assertion rather than a check. That is why core checks the result at runtime.
manual
Declares the hook without running it. $services.<hook> is the switch:
import { Base, withRaf } from '@studiometa/js-toolkit';
class SliderItem extends withRaf(Base, { manual: true }) {
static config = { name: 'SliderItem' };
ticked({ delta }) {}
onSelected() {
this.$services.ticked.start();
}
onSettled() {
this.$services.ticked.stop();
}
}$services is declared in the type as ServiceHandles<'ticked'>, so the key completes and a wrong name is a type error. Each handle is a Toggle.
immediate
Asks for the first delivery at subscribe time. The sources with a current value honour it; the ones without do nothing. withInView defaults it to true.
ticked can return a render function
withRaf is the one hook whose return value is used: return a function and it runs in the write phase of the same frame.
import { Base, withRaf } from '@studiometa/js-toolkit';
class Follower extends withRaf(Base) {
static config = { name: 'Follower' };
ticked({ delta }) {
const x = this.measure(delta);
return () => {
this.$el.style.transform = `translateX(${x}px)`;
};
}
measure(delta) {
return delta;
}
}The raf service collects the render functions of its callbacks and cancels a render whose subscriber left between the two phases.
One hook per class, and that is the limit
A mixin binds one subscription, under one name, for each mount cycle. A component whose subscriptions are one per markup declaration — one per attribute, with its own modifiers and its own threshold, known only when the element is read — has no method to name and no fixed count. It calls subscribe() itself:
mounted() {
return useScroll(this.$refs.panel).subscribe((props) => {});
}on<Event> has the same shape: a handler name belongs to the class, while a set of events can be data. Neither limit is about a build step — withRaf(Base) is an ordinary call, and the name is fixed by how the class is written.
Failures
A subscriber that throws is skipped and reported as callback.service-failed. Core dispatches the diagnostic first and calls reportError() only when no listener cancelled the event.