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
8 changes: 6 additions & 2 deletions packages/v4/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
31 changes: 31 additions & 0 deletions packages/v4/src/exports.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ import {
useDrag,
useInView,
useMutation,
usePointer,
useScrollProgress,
watchAttributes,
withDrag,
withInView,
withMutation,
withPointer,
withScrollProgress,
type DefineManifestOptions,
type DomMutation,
Expand All @@ -27,6 +29,7 @@ import {
type DragMixinOptions,
type DragOptions,
type DragProps,
type ElementPointerProps,
type InViewHook,
type InViewMixinOptions,
type ExtendableDetail,
Expand All @@ -35,6 +38,9 @@ import {
type MutationHook,
type MutationMixinOptions,
type MutationProps,
type PointerHook,
type PointerMixinOptions,
type PointerProps,
type AttributeChange,
type AttributeWatcher,
type ContextCallback,
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -248,6 +260,25 @@ describe('the package entry points', () => {
expectTypeOf<MutationMixinOptions>().toMatchTypeOf<MutationObserverInit>();
});

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<Service<PointerProps>>();
expectTypeOf(usePointer(document.documentElement)).toEqualTypeOf<
Service<ElementPointerProps>
>();
expectTypeOf<ElementPointerProps>().toMatchTypeOf<PointerProps>();
expectTypeOf<PointerHook>().toMatchTypeOf<{
moved?: (props: ElementPointerProps) => void;
}>();
expectTypeOf<PointerMixinOptions>().toMatchTypeOf<{
target?: (instance: Base) => Element;
}>();
});

it('exports manifest generation from the root and symbol subpaths', () => {
expect(defineManifestFromSubpath).toBe(defineManifest);
expect(fromMetaGlobFromSubpath).toBe(fromMetaGlob);
Expand Down
1 change: 1 addition & 0 deletions packages/v4/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export {
export {
usePointer,
withPointer,
type ElementPointerProps,
type PointerHook,
type PointerMixinOptions,
type PointerProps,
Expand Down
Loading
Loading