Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions packages/v4/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ So the opt-in is the standalone, element-scoped `watchAttributes(element, callba
- **The records join the shared queue.** The element observer is drained wherever the engine drains its own — `whenDOMSettled()` included — and its changes are reported from the same background task, as step 5 above. So `swap()` covers a watched attribute exactly as it covers a mount, and there is no second timeline to reason about.
- **After the framework, deliberately.** A callback must see settled component lifecycle and declared options rather than a half-reconciled batch. A component which stops its watcher from its mounted cleanup during same-batch termination therefore hears nothing about the accompanying attribute change. Nothing in the framework reads these arbitrary attributes, so no framework decision can depend on a callback, and the reverse order would have no reader.
- **Coalesced like options.** Several writes to one attribute in a batch are one change, from the first old value to the final DOM value, and a rewrite ending where it started is not a change at all — the rule `$optionChanged()` already follows.
- **Not the general-purpose observer.** Watching a subtree, character data, or a node the framework knows nothing about is `useMutation()` in section 8, which owns its own observer and reports raw records on the platform's own timing. This helper is the one to reach for whenever the question is what an attribute became.
- Delivered as one payload object: `{ name, value, previousValue }`, raw attribute strings, `null` on either side for an absent attribute. It is the **entire** attribute set of the element, framework names included; a caller narrows by prefix, which is what a `data-on:` or `data-bind:` family wants anyway. Callback failures are isolated through `EVENTS.diagnostic` with code `DIAGNOSTICS.callback.attributeWatcherFailed`, so one watcher cannot stop another.

Measured from the preceding `origin/main` with esbuild tree shaking and minification, `morphdom` external and gzip level 9:
Expand Down Expand Up @@ -722,7 +723,7 @@ Nothing in v4 needs it today, so nothing implements it: this project does not ad

## 8. Services — lazy, reference-counted — implemented

A service is a shared source of props components subscribe to: `ticked`, `scrolled`, `resized`, `moved`, `dragged`, `intersected`. `KeyService` and `LoadService` are not ported — a `keydown` listener and `window.onload` need no service around them.
A service is a shared source of props components subscribe to: `ticked`, `scrolled`, `resized`, `moved`, `dragged`, `intersected`, `mutated`. `KeyService` and `LoadService` are not ported — a `keydown` listener and `window.onload` need no service around them.

> **Hardened after an adversarial review** (`SERVICES-REVIEW.md`, 2026-08-12): 17 confirmed defects, each with a regression test, plus the API changes recorded below. Three claims this section used to make were falsified by that review and are corrected in place.
>
Expand All @@ -734,9 +735,9 @@ A service is a shared source of props components subscribe to: `ticked`, `scroll

It also exposed a defect of its own, which had been invisible while nothing read a run's first props: `deltaX`/`deltaY` were measured against the position the _previous_ run ended at, so a service restarted after the page had moved announced a scroll nobody performed — 100 px of it, in the test that now guards it. A run's first props carry no movement.

- **Scoped to a target.** `useScroll(target?)` takes an element or the window, `useResize(target?)`, `useScrollProgress(target, options?)` and `useInView(target, init?)` take an element, and `useDrag(el)` takes an `HTMLElement` or an `SVGElement`; `useWindowScroll()` and `useWindowSize()` name the default cases, the split VueUse, solid-primitives, react-use and runed all make. `useRaf()`, `usePointer()` and `useBreakpoint()` have nothing to scope — the frame is the clock, the pointer is read from the window, and a media query answers about the viewport. `useScroll(document.documentElement)` is the window service, because the document scroller dispatches its events at the document.
- **Scoped to a target.** `useScroll(target?)` takes an element or the window, `useResize(target?)`, `useScrollProgress(target, options?)` and `useInView(target, init?)` take an element, `useMutation(target, init?)` takes any node, and `useDrag(el)` takes an `HTMLElement` or an `SVGElement`; `useWindowScroll()` and `useWindowSize()` name the default cases, the split VueUse, solid-primitives, react-use and runed all make. `useRaf()`, `usePointer()` and `useBreakpoint()` have nothing to scope — the frame is the clock, the pointer is read from the window, and a media query answers about the viewport. `useScroll(document.documentElement)` is the window service, because the document scroller dispatches its events at the document.
- **One instance per target and service options,** keyed in a `WeakMap` by `perTarget()`. This is lifecycle bookkeeping rather than throughput: reference counting only means something against a target, so the last subscriber of one element must release that element's observer and leave the others running. `useDrag()` includes its axis, inertia, damping and threshold choices; `useInView()` includes every `IntersectionObserverInit` field in the key and gives object roots stable weak identities; `useScrollProgress()` includes its resolved offset. Sharing one observer across targets was measured indifferent — the widespread claim traces to a single 2017 measurement, and 500 idle observers now cost ~0.02 ms/frame in total (`service.bench.ts`) — so nothing tries to group them.
- **Bound per mount cycle, by a mixin.** `withRaf`/`withScroll`/`withResize`/`withScrollProgress`/`withPointer`/`withDrag`/`withInView` override `mounted()`, subscribe the component's `ticked`/`scrolled`/`resized`/`scrolledInView`/`moved`/`dragged`/`intersected` method, and hand the unsubscribe back as a cleanup — so `$destroy()` releases it and a remount subscribes again, with `Base` knowing nothing about services. The mixin is the primitive because it needs no build step; `@withScroll()` is the decorator sugar over it, and both are tree-shakeable: an unimported service cannot make a hook silently do nothing. `withInView` observes a component that is already mounted; it does not replace the `visible` or `in-view` mount strategy. `withScrollProgress` keeps the useful v3 `scrolledInView` hook but not the old decorator's damping or mount control. Its first raw measurement is immediate by default, and a render returned by the hook goes through the instance `$write()` lane.
- **Bound per mount cycle, by a mixin.** `withRaf`/`withScroll`/`withResize`/`withScrollProgress`/`withPointer`/`withDrag`/`withInView`/`withMutation` override `mounted()`, subscribe the component's `ticked`/`scrolled`/`resized`/`scrolledInView`/`moved`/`dragged`/`intersected`/`mutated` method, and hand the unsubscribe back as a cleanup — so `$destroy()` releases it and a remount subscribes again, with `Base` knowing nothing about services. The mixin is the primitive because it needs no build step; `@withScroll()` is the decorator sugar over it, and both are tree-shakeable: an unimported service cannot make a hook silently do nothing. `withInView` observes a component that is already mounted; it does not replace the `visible` or `in-view` mount strategy. `withScrollProgress` keeps the useful v3 `scrolledInView` hook but not the old decorator's damping or mount control. Its first raw measurement is immediate by default, and a render returned by the hook goes through the instance `$write()` lane.
- **One method name per mixin, and it is the service's own.** A hook is sugar for the default target; `target` is the only option. There is no `hook` option: two layers with the same name collapsed into one subscription with no warning, a custom name lost the hook's props typing entirely, and renaming one compiled, shipped and silently stopped updating. Any other target is an explicit subscription in `mounted()`, where the returned unsubscribe is the cleanup:

```js
Expand All @@ -745,6 +746,10 @@ A service is a shared source of props components subscribe to: `ticked`, `scroll
}
```

- **Mutations are a service too — `useMutation()`.** Section 3 keeps one filtered `MutationObserver` for the framework's own names, and opens that engine to one element's attributes through `watchAttributes()`. Neither covers "tell me when anything under this node changes", which is why `@studiometa/ui`'s `Disclosure` was writing an observer by hand. `useMutation(node, init?)` is that observer as a service, and it is the last resort of the three: `watchAttributes()` first for an attribute, since it coalesces and reports after component lifecycle has settled, and the registry's own observer for everything the framework already reconciles. This one delivers on the platform's own timing, and a subscriber that needs the framework's ordering awaits `whenDOMSettled()` from its callback.

Its props are `{ records }` and it **keeps nothing after the delivery**. A `childList` record holds the nodes it removed, so a service retaining the last batch — as v3's persistent props object did — keeps a detached subtree alive for the life of the page. That also makes `hasProps()` honest, for the reason the frame tick has none between two frames: a batch is a mutation that happened, not a state that holds, so `props()` is empty between deliveries and `{ immediate: true }` waits for a real one. Its key is a **canonical** init rather than the raw options: property order, an unsorted or repeated `attributeFilter`, and the platform's own `attributeOldValue`/`characterDataOldValue` inferences all describe one observation and must not buy a second observer. The default observation is `{ childList: true, subtree: true }`, because attributes of one element are `watchAttributes()`'s job.

- **Suspendable within a cycle — `toggle()`.** A mount cycle is the right span for most subscriptions and the wrong one for a component that needs the frame loop only while something settles. `toggle(subscribe)` returns `{ isActive, start, stop }` over anything that hands back its own unsubscribe, with `start` and `stop` bound:

```js
Expand Down
8 changes: 8 additions & 0 deletions packages/v4/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,14 @@
"types": "./dist/subpaths/usePrefersReducedMotion.d.ts",
"import": "./dist/subpaths/usePrefersReducedMotion.js"
},
"./useMutation": {
"types": "./dist/subpaths/useMutation.d.ts",
"import": "./dist/subpaths/useMutation.js"
},
"./withMutation": {
"types": "./dist/subpaths/withMutation.d.ts",
"import": "./dist/subpaths/withMutation.js"
},
"./usePointer": {
"types": "./dist/subpaths/usePointer.d.ts",
"import": "./dist/subpaths/usePointer.js"
Expand Down
26 changes: 25 additions & 1 deletion packages/v4/src/exports.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ import {
subscribeContext,
useDrag,
useInView,
useMutation,
useScrollProgress,
watchAttributes,
withDrag,
withInView,
withMutation,
withScrollProgress,
type DefineManifestOptions,
type DomMutation,
Expand All @@ -30,6 +32,9 @@ import {
type ExtendableDetail,
type Extension,
type InViewProps,
type MutationHook,
type MutationMixinOptions,
type MutationProps,
type AttributeChange,
type AttributeWatcher,
type ContextCallback,
Expand All @@ -56,6 +61,9 @@ import useDragFromSubpath from '@studiometa/js-toolkit-v4/useDrag';
import useInViewFromSubpath, {
useInView as namedUseInViewFromSubpath,
} from '@studiometa/js-toolkit-v4/useInView';
import useMutationFromSubpath, {
useMutation as namedUseMutationFromSubpath,
} from '@studiometa/js-toolkit-v4/useMutation';
import useScrollProgressSubpath from '@studiometa/js-toolkit-v4/useScrollProgress';
import watchAttributesFromSubpath, {
watchAttributes as namedWatchAttributesFromSubpath,
Expand All @@ -72,6 +80,9 @@ import withDragFromSubpath from '@studiometa/js-toolkit-v4/withDrag';
import withInViewFromSubpath, {
withInView as namedWithInViewFromSubpath,
} from '@studiometa/js-toolkit-v4/withInView';
import withMutationFromSubpath, {
withMutation as namedWithMutationFromSubpath,
} from '@studiometa/js-toolkit-v4/withMutation';
import withScrollProgressSubpath from '@studiometa/js-toolkit-v4/withScrollProgress';

function toolkitDiagnosticDetailTypeAssertions(detail: ToolkitDiagnosticDetail): void {
Expand Down Expand Up @@ -130,7 +141,7 @@ describe('the package entry points', () => {
it('keeps the framework on the root entry, without the utils or removed exports', async () => {
expect(typeof Base).toBe('function');
const root = (await import('@studiometa/js-toolkit-v4')) as Record<string, unknown>;
expect(Object.keys(root)).toHaveLength(79);
expect(Object.keys(root)).toHaveLength(81);
expect(root.clamp).toBeUndefined();
expect(root.smoothTo).toBeUndefined();
for (const removed of [
Expand Down Expand Up @@ -224,6 +235,19 @@ describe('the package entry points', () => {
expectTypeOf<InViewMixinOptions>().toMatchTypeOf<IntersectionObserverInit>();
});

it('serves useMutation and withMutation from the root and their symbol subpaths', () => {
expect(useMutationFromSubpath).toBe(useMutation);
expect(namedUseMutationFromSubpath).toBe(useMutation);
expect(withMutationFromSubpath).toBe(withMutation);
expect(namedWithMutationFromSubpath).toBe(withMutation);
expectTypeOf(useMutation(document)).toEqualTypeOf<Service<MutationProps>>();
expectTypeOf<MutationProps>().toEqualTypeOf<{ readonly records: readonly MutationRecord[] }>();
expectTypeOf<MutationHook>().toMatchTypeOf<{
mutated?: (props: MutationProps) => void;
}>();
expectTypeOf<MutationMixinOptions>().toMatchTypeOf<MutationObserverInit>();
});

it('exports manifest generation from the root and symbol subpaths', () => {
expect(defineManifestFromSubpath).toBe(defineManifest);
expect(fromMetaGlobFromSubpath).toBe(fromMetaGlob);
Expand Down
7 changes: 7 additions & 0 deletions packages/v4/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,13 @@ export {
type ServiceMixinOptions,
} from './services/mixin.js';
export { useMediaQuery, usePrefersReducedMotion, type MediaQueryProps } from './services/media.js';
export {
useMutation,
withMutation,
type MutationHook,
type MutationMixinOptions,
type MutationProps,
} from './services/mutation.js';
export {
usePointer,
withPointer,
Expand Down
Loading
Loading