From bf5f981e44c912693d67fd6ec0de31bf3a5dbec5 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Sun, 16 Aug 2026 10:28:54 +0000 Subject: [PATCH 1/2] feat(v4)!: give the pointer service a target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `usePointer(el)` returns one lazy service per element whose props carry the pointer's position inside that element's box, so a consumer never writes `getBoundingClientRect()` for it. Without a target the service is the viewport singleton it already was. The targeted service subscribes to the singleton instead of listening again: one document listener set serves every target, and the `pointerId` tracking stays in one place. Its box is measured on demand and kept until a scroll, a resize or the target's own `ResizeObserver` can have moved it, because a mouse reports up to 1000 events a second and each read costs 1.7 µs against a clean layout and 31.6 µs behind a write. The spec counts the reads: 100 events, one read. BREAKING CHANGE: `withPointer` targets the component root by default and its hook receives `ElementPointerProps`, a superset of `PointerProps`. `PointerMixinOptions` is `ServiceMixinOptions`, so a `target` option must resolve to an element. `PointerProps` itself is unchanged, and a `moved()` reading `x`/`y` keeps working. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9 --- packages/v4/src/exports.spec.ts | 31 +++ packages/v4/src/index.ts | 1 + packages/v4/src/services/pointer.spec.ts | 279 ++++++++++++++++++++++- packages/v4/src/services/pointer.ts | 141 +++++++++++- 4 files changed, 440 insertions(+), 12 deletions(-) diff --git a/packages/v4/src/exports.spec.ts b/packages/v4/src/exports.spec.ts index b0b55837b..8bfa5e193 100644 --- a/packages/v4/src/exports.spec.ts +++ b/packages/v4/src/exports.spec.ts @@ -14,11 +14,13 @@ import { useDrag, useInView, useMutation, + usePointer, useScrollProgress, watchAttributes, withDrag, withInView, withMutation, + withPointer, withScrollProgress, type DefineManifestOptions, type DomMutation, @@ -27,6 +29,7 @@ import { type DragMixinOptions, type DragOptions, type DragProps, + type ElementPointerProps, type InViewHook, type InViewMixinOptions, type ExtendableDetail, @@ -35,6 +38,9 @@ import { type MutationHook, type MutationMixinOptions, type MutationProps, + type PointerHook, + type PointerMixinOptions, + type PointerProps, type AttributeChange, type AttributeWatcher, type ContextCallback, @@ -64,6 +70,9 @@ import useInViewFromSubpath, { import useMutationFromSubpath, { useMutation as namedUseMutationFromSubpath, } from '@studiometa/js-toolkit-v4/useMutation'; +import usePointerFromSubpath, { + usePointer as namedUsePointerFromSubpath, +} from '@studiometa/js-toolkit-v4/usePointer'; import useScrollProgressSubpath from '@studiometa/js-toolkit-v4/useScrollProgress'; import watchAttributesFromSubpath, { watchAttributes as namedWatchAttributesFromSubpath, @@ -83,6 +92,9 @@ import withInViewFromSubpath, { import withMutationFromSubpath, { withMutation as namedWithMutationFromSubpath, } from '@studiometa/js-toolkit-v4/withMutation'; +import withPointerFromSubpath, { + withPointer as namedWithPointerFromSubpath, +} from '@studiometa/js-toolkit-v4/withPointer'; import withScrollProgressSubpath from '@studiometa/js-toolkit-v4/withScrollProgress'; function toolkitDiagnosticDetailTypeAssertions(detail: ToolkitDiagnosticDetail): void { @@ -248,6 +260,25 @@ describe('the package entry points', () => { expectTypeOf().toMatchTypeOf(); }); + it('serves usePointer and withPointer from the root and their symbol subpaths', () => { + expect(usePointerFromSubpath).toBe(usePointer); + expect(namedUsePointerFromSubpath).toBe(usePointer); + expect(withPointerFromSubpath).toBe(withPointer); + expect(namedWithPointerFromSubpath).toBe(withPointer); + // The viewport pointer and the element-scoped one are one function. + expectTypeOf(usePointer()).toEqualTypeOf>(); + expectTypeOf(usePointer(document.documentElement)).toEqualTypeOf< + Service + >(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf<{ + moved?: (props: ElementPointerProps) => void; + }>(); + expectTypeOf().toMatchTypeOf<{ + target?: (instance: Base) => Element; + }>(); + }); + it('exports manifest generation from the root and symbol subpaths', () => { expect(defineManifestFromSubpath).toBe(defineManifest); expect(fromMetaGlobFromSubpath).toBe(fromMetaGlob); diff --git a/packages/v4/src/index.ts b/packages/v4/src/index.ts index 847a3303a..6ccd6e85b 100644 --- a/packages/v4/src/index.ts +++ b/packages/v4/src/index.ts @@ -122,6 +122,7 @@ export { export { usePointer, withPointer, + type ElementPointerProps, type PointerHook, type PointerMixinOptions, type PointerProps, diff --git a/packages/v4/src/services/pointer.spec.ts b/packages/v4/src/services/pointer.spec.ts index 5d2673e0f..ec848d266 100644 --- a/packages/v4/src/services/pointer.spec.ts +++ b/packages/v4/src/services/pointer.spec.ts @@ -1,5 +1,15 @@ -import { describe, expect, it } from 'vitest'; -import { usePointer, type PointerProps } from './pointer.js'; +import { afterEach, describe, expect, expectTypeOf, it } from 'vitest'; +import { Base } from '../Base.js'; +import { + usePointer, + withPointer, + type ElementPointerProps, + type PointerHook, + type PointerMixinOptions, + type PointerProps, +} from './pointer.js'; +import type { Service } from './service.js'; +import type { Toggle } from './toggle.js'; function snapshot(props: PointerProps) { return { ...props }; @@ -9,6 +19,70 @@ function move(x: number, y: number): void { document.dispatchEvent(new PointerEvent('pointermove', { clientX: x, clientY: y })); } +/** A box at a known place in the viewport, whichever way the page is scrolled. */ +function fixedBox(left: number, top: number, width: number, height: number): HTMLElement { + const el = document.createElement('div'); + el.style.cssText = `position:fixed;left:${left}px;top:${top}px;width:${width}px;height:${height}px`; + document.body.append(el); + return el; +} + +/** + * Count the layout reads one element serves, without touching the prototype. + * `box()` measures without counting, so a test can check geometry itself. + */ +function countReads(el: Element) { + const box = el.getBoundingClientRect.bind(el); + let reads = 0; + el.getBoundingClientRect = () => { + reads += 1; + return box(); + }; + return { box, reads: () => reads }; +} + +/** A `ResizeObserver` delivers after the frame's callbacks, so two frames is the safe wait. */ +async function frames(count = 2): Promise { + for (let index = 0; index < count; index += 1) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + } +} + +async function scrollWindowTo(y: number): Promise { + const scrolled = new Promise((resolve) => + window.addEventListener('scroll', () => resolve(), { once: true }), + ); + window.scrollTo(0, y); + await scrolled; +} + +function typeAssertions(instance: Follower): void { + expectTypeOf(usePointer()).toEqualTypeOf>(); + expectTypeOf(usePointer(document.body)).toEqualTypeOf>(); + expectTypeOf().toMatchTypeOf<{ + moved?: (props: ElementPointerProps) => void; + }>(); + expectTypeOf(instance.$services.moved).toEqualTypeOf(); + // @ts-expect-error service props belong to the service + usePointer(document.body).props().relativeX = 1; +} +void typeAssertions; + +const mixinOptionsTypeAssertions: PointerMixinOptions = { + target: (instance) => instance.$el, + manual: true, + immediate: false, +}; +void mixinOptionsTypeAssertions; + +class Follower extends withPointer(Base) { + seen: ElementPointerProps[] = []; + + moved(props: ElementPointerProps): void { + this.seen.push({ ...props }); + } +} + describe('usePointer', () => { it('starts centered, so progress means something before the first move', () => { const props = usePointer().props(); @@ -150,3 +224,204 @@ describe('usePointer', () => { expect(usePointer().props().event).toBeNull(); }); }); + +describe('usePointer(target)', () => { + afterEach(async () => { + window.scrollTo(0, 0); + document.body.innerHTML = ''; + await frames(1); + }); + + it('places the pointer inside a positioned element', () => { + const el = fixedBox(100, 50, 200, 100); + const seen: ElementPointerProps[] = []; + const unsubscribe = usePointer(el).subscribe((props) => seen.push({ ...props })); + + move(150, 80); + expect(seen.at(-1)?.x).toBe(150); + expect(seen.at(-1)?.relativeX).toBe(50); + expect(seen.at(-1)?.relativeY).toBe(30); + expect(seen.at(-1)?.relativeProgressX).toBeCloseTo(0.25, 5); + expect(seen.at(-1)?.relativeProgressY).toBeCloseTo(0.3, 5); + + // Outside the box the position keeps its sign instead of clamping. + move(80, 80); + expect(seen.at(-1)?.relativeX).toBe(-20); + expect(seen.at(-1)?.relativeProgressX).toBeCloseTo(-0.1, 5); + + unsubscribe(); + }); + + it('answers a cold read from the shared pointer, placed in the box', () => { + const el = fixedBox(100, 50, 200, 100); + const props = usePointer(el).props(); + + expect(props.event).toBeNull(); + expect(props.relativeX).toBe(usePointer().props().x - 100); + expect(props.relativeY).toBe(usePointer().props().y - 50); + }); + + it('delivers immediately once the shared pointer has been seen', () => { + const el = fixedBox(100, 50, 200, 100); + const cold: ElementPointerProps[] = []; + const first = usePointer(el).subscribe((props) => cold.push({ ...props }), { + immediate: true, + }); + expect(cold).toEqual([]); + + move(150, 80); + expect(cold).toHaveLength(1); + + const warm: ElementPointerProps[] = []; + const second = usePointer(el).subscribe((props) => warm.push({ ...props }), { + immediate: true, + }); + expect(warm.at(-1)?.relativeX).toBe(50); + expect(cold).toHaveLength(1); + + first(); + second(); + }); + + it('reads the target box once for a burst of events, not once per event', () => { + const el = fixedBox(100, 50, 200, 100); + const { reads } = countReads(el); + const unsubscribe = usePointer(el).subscribe(() => {}); + + expect(reads()).toBe(0); + for (let index = 0; index < 100; index += 1) { + move(100 + index, 50 + index); + } + expect(reads()).toBe(1); + + unsubscribe(); + }); + + it('measures again when the page scrolled under the pointer', async () => { + const el = document.createElement('div'); + el.style.cssText = 'position:absolute;left:100px;top:800px;width:200px;height:100px'; + const spacer = document.createElement('div'); + spacer.style.cssText = 'height:3000px'; + document.body.append(el, spacer); + + const seen: ElementPointerProps[] = []; + const { box, reads } = countReads(el); + const unsubscribe = usePointer(el).subscribe((props) => seen.push({ ...props })); + + const before = box(); + move(150, before.top + 20); + expect(seen.at(-1)?.relativeY).toBe(20); + + await scrollWindowTo(300); + const after = box(); + expect(after.top).toBe(before.top - 300); + + move(150, after.top + 20); + expect(seen.at(-1)?.relativeY).toBe(20); + // One read for the first box, one for the box the scroll moved. + expect(reads()).toBe(2); + + unsubscribe(); + }); + + it('measures again when the target resized under the pointer', async () => { + const el = fixedBox(100, 50, 200, 100); + const seen: ElementPointerProps[] = []; + const unsubscribe = usePointer(el).subscribe((props) => seen.push({ ...props })); + + move(150, 80); + expect(seen.at(-1)?.relativeProgressX).toBeCloseTo(0.25, 5); + + el.style.width = '400px'; + await frames(); + + move(150, 80); + expect(seen.at(-1)?.relativeProgressX).toBeCloseTo(0.125, 5); + + unsubscribe(); + }); + + it('shares one service per target and releases the shared pointer with its last subscriber', () => { + const el = fixedBox(100, 50, 200, 100); + const other = fixedBox(0, 0, 10, 10); + expect(usePointer(el)).toBe(usePointer(el)); + expect(usePointer(el)).not.toBe(usePointer(other)); + + let first = 0; + let second = 0; + const unsubscribeFirst = usePointer(el).subscribe(() => { + first += 1; + }); + const unsubscribeSecond = usePointer(el).subscribe(() => { + second += 1; + }); + + move(150, 80); + expect([first, second]).toEqual([1, 1]); + + unsubscribeFirst(); + move(160, 80); + expect([first, second]).toEqual([1, 2]); + + unsubscribeSecond(); + move(170, 80); + expect([first, second]).toEqual([1, 2]); + // The shared viewport pointer is reference-counted through it. + expect(usePointer().props().event).toBeNull(); + }); +}); + +describe('withPointer', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('reads the pointer in the component root box', () => { + const el = fixedBox(100, 50, 200, 100); + const instance = new Follower(el).$mount(); + + move(150, 80); + expect(instance.seen.at(-1)?.x).toBe(150); + expect(instance.seen.at(-1)?.relativeX).toBe(50); + expect(instance.seen.at(-1)?.relativeY).toBe(30); + + instance.$terminate(); + move(160, 80); + expect(instance.seen).toHaveLength(1); + }); + + it('shares one service and one layout read between two components on one target', () => { + const box = fixedBox(100, 50, 200, 100); + const { reads } = countReads(box); + const seen: Array<[string, number]> = []; + + class Watcher extends withPointer(Base, { target: () => box }) { + moved(props: ElementPointerProps): void { + seen.push([this.$el.id, props.relativeX]); + } + } + + const hostA = document.createElement('div'); + hostA.id = 'a'; + const hostB = document.createElement('div'); + hostB.id = 'b'; + document.body.append(hostA, hostB); + const a = new Watcher(hostA).$mount(); + const b = new Watcher(hostB).$mount(); + + move(150, 80); + expect(seen).toEqual([ + ['a', 50], + ['b', 50], + ]); + expect(reads()).toBe(1); + + a.$terminate(); + move(160, 80); + expect(seen.at(-1)).toEqual(['b', 60]); + + b.$terminate(); + move(170, 80); + expect(seen).toHaveLength(3); + }); +}); diff --git a/packages/v4/src/services/pointer.ts b/packages/v4/src/services/pointer.ts index d88e5eda6..bfbac1790 100644 --- a/packages/v4/src/services/pointer.ts +++ b/packages/v4/src/services/pointer.ts @@ -1,6 +1,6 @@ import { getSharedRuntimeSlot } from '../shared-runtime.js'; import { createServiceMixin, type ServiceHandles, type ServiceMixinOptions } from './mixin.js'; -import { createService, type MutableProps, type Service } from './service.js'; +import { createService, perTarget, type MutableProps, type Service } from './service.js'; /** Pointer state for both viewport axes. */ export interface PointerProps { @@ -21,6 +21,16 @@ export interface PointerProps { readonly progressY: number; } +/** Pointer state placed in a target element's box, beside the viewport one. */ +export interface ElementPointerProps extends PointerProps { + /** Position inside the target's box, from its top-left corner. */ + readonly relativeX: number; + readonly relativeY: number; + /** Position over the target's box, from `0` to `1` inside it and past that range outside. */ + readonly relativeProgressX: number; + readonly relativeProgressY: number; +} + /** Pointer event types observed by the service. */ const EVENTS = ['pointermove', 'pointerdown', 'pointerup', 'pointercancel'] as const; @@ -106,29 +116,140 @@ function createPointerService(): Service { }); } -const pointerState = /* @__PURE__ */ getSharedRuntimeSlot<{ +/** + * Place the shared pointer in a target element's box. + * + * The maths belongs to the service rather than to each consumer, and so does + * the layout read it needs: `getBoundingClientRect()` on every pointer event + * would be one forced measurement per event, and a mouse reports up to 1000 of + * them a second. + */ +function createElementPointerService(target: Element): Service { + const pointer = usePointer(); + const props: MutableProps = { + ...pointer.props(), + relativeX: 0, + relativeY: 0, + relativeProgressX: 0, + relativeProgressY: 0, + }; + + /** + * The target's box, kept between the events that can move it. + * + * It is held only while the service runs, because that is when the listeners + * which drop it are attached. The layout box is deliberately the frame of + * reference: a transform the consumer applies from `moved()` does not + * invalidate it, so a hover effect cannot feed its own output back in. + */ + let box: DOMRect | null = null; + let isRunning = false; + + function measure(): DOMRect { + if (box) { + return box; + } + const rect = target.getBoundingClientRect(); + if (isRunning) { + box = rect; + } + return rect; + } + + function sync(source: PointerProps): ElementPointerProps { + props.event = source.event; + props.isDown = source.isDown; + props.x = source.x; + props.y = source.y; + props.deltaX = source.deltaX; + props.deltaY = source.deltaY; + props.maxX = source.maxX; + props.maxY = source.maxY; + props.progressX = source.progressX; + props.progressY = source.progressY; + + const rect = measure(); + props.relativeX = source.x - rect.left; + props.relativeY = source.y - rect.top; + // A collapsed box has no inside to be at a fraction of. + props.relativeProgressX = rect.width === 0 ? 0 : props.relativeX / rect.width; + props.relativeProgressY = rect.height === 0 ? 0 : props.relativeY / rect.height; + + return props; + } + + return createService({ + // A cold read is the shared pointer's own answer, placed in the box. + props: () => sync(pointer.props()), + hasProps: () => pointer.props().event !== null, + start(emit) { + isRunning = true; + const invalidate = () => { + box = null; + }; + + // Capture reaches every scroller, the document included. + document.addEventListener('scroll', invalidate, { passive: true, capture: true }); + window.addEventListener('resize', invalidate, { passive: true }); + const observer = new ResizeObserver(invalidate); + observer.observe(target); + const unsubscribe = pointer.subscribe((source) => emit(sync(source))); + + return () => { + isRunning = false; + box = null; + unsubscribe(); + observer.disconnect(); + document.removeEventListener('scroll', invalidate, { capture: true }); + window.removeEventListener('resize', invalidate); + // Release the event so its target subtree can be collected. + props.event = null; + }; + }, + }); +} + +interface PointerRuntimeState { service: Service | undefined; -}>('service:pointer', 1, () => ({ service: undefined })); + readonly services: (target: Element) => Service; +} + +const pointerState = /* @__PURE__ */ getSharedRuntimeSlot( + 'service:pointer', + 2, + () => ({ + service: undefined, + services: perTarget(createElementPointerService), + }), +); /** Use the viewport-relative pointer service. */ -export function usePointer(): Service { +export function usePointer(): Service; +/** Use one lazy service per target, adding the pointer's position inside its box. */ +export function usePointer(target: Element): Service; +export function usePointer(target?: Element): Service | Service { + if (target) { + return pointerState.services(target); + } pointerState.service ??= createPointerService(); return pointerState.service; } /** The method `withPointer()` subscribes for the component. */ export interface PointerHook { - moved?(props: PointerProps): void; + moved?(props: ElementPointerProps): void; } -export type PointerMixinOptions = ServiceMixinOptions; +export type PointerMixinOptions = ServiceMixinOptions; -/** Subscribe `moved()` to the viewport pointer for each mount cycle. */ +/** + * Subscribe `moved()` to the pointer for each mount cycle. The root element is the default target, so a component reads the pointer both in the viewport and inside its own box. + */ export const withPointer = /* @__PURE__ */ createServiceMixin< PointerHook & ServiceHandles<'moved'>, - void + Element >({ hook: 'moved', - target: () => undefined, - use: () => usePointer(), + target: (instance) => instance.$el, + use: (target) => usePointer(target), }); From a5f2630118e1afcebc989eebb3c9dff3b66ec498 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Sun, 16 Aug 2026 10:29:02 +0000 Subject: [PATCH 2/2] docs(v4): record the targeted pointer in DESIGN section 8 Section 8 said the pointer had nothing to scope and that v4 had dropped the element target v3 took. Both are now wrong. The new bullet keeps the reasoning that matters: why the relative fields sit beside the viewport ones instead of replacing them, why the targeted service reuses the singleton, and what the layout read costs when it is not cached. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9 --- packages/v4/DESIGN.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/v4/DESIGN.md b/packages/v4/DESIGN.md index f784e07f0..9e8029638 100644 --- a/packages/v4/DESIGN.md +++ b/packages/v4/DESIGN.md @@ -735,7 +735,7 @@ 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, `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. +- **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. `usePointer(target?)` takes an element too, and answers about the viewport without one. `useRaf()` and `useBreakpoint()` have nothing to scope — the frame is the clock, 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`/`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: @@ -796,7 +796,11 @@ A service is a shared source of props components subscribe to: `ticked`, `scroll - **Extents are observed, not sampled once.** A scroll container's own box never grows with its content, and content growing announces itself with no `scroll` and no `resize`: `maxY` stayed at 400 for content that had gone from 500 to 5000 px. The scroll service therefore watches the scroller **and its element children** with a `ResizeObserver`, plus a `childList` `MutationObserver` to keep that set in sync — `1 + n` observed boxes per scroller, lazy and released with the last subscriber like everything else. - **Props are flat, one per axis, and nothing derivable is a field.** `ScrollProps` is `x`/`y`, `deltaX`/`deltaY`, `maxX`/`maxY`, `progressX`/`progressY`, `directionX`/`directionY`, `isScrolling`. The grouped objects (`last`, `delta`, `max`, `progress`, `direction`, `changed`) are gone, and so are the derivations v3 shipped as fields: `lastX` is `x - deltaX`, `changedX` is `deltaX !== 0`. `directionX`/`directionY` are `-1 | 0 | 1`, one signed value that **multiplies**, replacing `isUp`/`isRight`/`isDown`/`isLeft` — which also settles the collision between a `ScrollProps.isDown` meaning "scrolling down" and a `PointerProps.isDown` meaning "pressed". `PointerProps` and `DragProps` follow the same convention, which flattens `origin`, `distance` and `final`; drag drops `isGrabbing`/`hasInertia`/`target`, all readings of `mode`, and `DragMode` gains `idle` for what `props()` reports outside a gesture. A handler destructures what it uses — `scrolled({ deltaY, directionY })` — instead of reaching through a group. - **Every prop field is `readonly`, and the props object belongs to its service.** It is valid for the duration of the call that received it: a service may hand the same object to every subscriber and overwrite it on the next update, which is what the sampled sources do rather than allocate per frame. `{ ...props }` is how you keep one. Without `readonly`, `useScroll().subscribe((p) => { p.y = 999 })` compiled and corrupted every other subscriber on the page. What a callback may return is a type parameter too, so `RafRender` is enforced — `useRaf().subscribe(() => 42)` used to compile and run a stray return as a DOM mutation every frame. -- **What the simplification dropped.** `PointerService` is pointer-events-only and viewport-relative (v3 branched on `TouchEvent` and took a target element), and follows one `pointerId` at a time so a second finger cannot end a live gesture; `ResizeService` keeps `width`/`height`/`ratio`/`orientation` and drops `breakpoints`/`activeBreakpoints`; `DragService` drops `props.MODES` from the props and fixes the `dragTreshold` spelling. +- **The pointer is placed in a box — `usePointer(target)`.** v3 shipped element-relative coordinates as `withRelativePointer`, a decorator whose whole content was a target and the subtraction. v4 puts both in the service: `usePointer()` is the viewport singleton it always was, `usePointer(el)` is one lazy service per target, and `ElementPointerProps extends PointerProps` with `relativeX`/`relativeY` and `relativeProgressX`/`relativeProgressY` beside the viewport fields — a superset, so `x` never changes meaning with the way the service was obtained. The targeted service **subscribes to the singleton** rather than listening again, so one document listener set serves every target and the `pointerId` tracking is the same code. `withPointer` therefore defaults its target to `$el`, like every other targeted mixin: a component asking about the pointer nearly always asks in relation to itself, and the viewport fields are still in the same object. + + **The box is cached, because the read is the expensive half.** `getBoundingClientRect()` is a layout read and a mouse reports up to 1000 events a second. Measured in Chromium over 1000 reads: **1.7 µs** each against a clean layout and **31.6 µs** each when a write sits between them — the forced reflow, which is the realistic case since the effect being driven writes to the DOM. So the box is measured on demand and kept until a `scroll` (captured at the document, so every scroller counts), a `resize`, or the target's own `ResizeObserver` can have moved it: 1000 events cost **one** read instead of 1000, asserted by counting them in the spec. The layout box is deliberately the frame of reference — a transform the consumer applies from `moved()` does not invalidate it, so a hover effect cannot feed its own output back in. + +- **What the simplification dropped.** `PointerService` is pointer-events-only (v3 branched on `TouchEvent`), and follows one `pointerId` at a time so a second finger cannot end a live gesture; `ResizeService` keeps `width`/`height`/`ratio`/`orientation` and drops `breakpoints`/`activeBreakpoints`; `DragService` drops `props.MODES` from the props and fixes the `dragTreshold` spelling. - **Closed sets of strings are named, and the type is derived from the name.** `DRAG_MODES` is a module-level `as const` object, with `DragMode = (typeof DRAG_MODES)[keyof typeof DRAG_MODES]`. This partly reverses the line above, and the reversal is narrower than it looks: what v3 shipped was `props.MODES`, a copy of the set on **every emission**, which deserved to go. A module export is a different thing, and the original decision — "the `DragMode` union types it" — weighed only the TypeScript audience. The first-class audience here writes components in plain JavaScript with **no build step**, and a literal union gives them nothing: no completion, no typo protection, no way to discover the set at all. `DRAG_MODES.INERTIA` gives all three, the literals still type-check, and deriving the type from the object keeps one source of truth. This is the pattern for every closed set of strings in the framework, not just this one. - **Breakpoints are their own source — `useBreakpoint()`.** A media query answers about the viewport, so a `breakpoint` field of `ResizeProps` said nothing about the element that service was observing. It is backed by `matchMedia` `change` listeners, which emit on **crossings** rather than once per resize frame and are the only mechanism that reports a change of the reader's font size. `setBreakpoints()` replaces the named set — the values v3 ships are only the default — and re-emits at once instead of leaving a stale name until something unrelated resized. The matching `MediaQueryList` objects are built once instead of once per breakpoint per resize, which measured 5.2× slower. When `defineFeatures` lands it carries the set; this setter is what it will call.