Skip to content

feat(v4)!: element-relative pointer coordinates, at the service level - #827

Merged
titouanmathis merged 2 commits into
mainfrom
feature/v4-relative-pointer
Aug 16, 2026
Merged

feat(v4)!: element-relative pointer coordinates, at the service level#827
titouanmathis merged 2 commits into
mainfrom
feature/v4-relative-pointer

Conversation

@titouanmathis

Copy link
Copy Markdown
Contributor

Element-relative pointer coordinates come back, at the service level. v3 shipped them as withRelativePointer, a decorator whose whole content was a target and one subtraction; v4 puts both in the service, so nothing re-implements the maths and nothing calls getBoundingClientRect() in a component.

The API

usePointer();      // Service<PointerProps>        — the viewport singleton, unchanged
usePointer(el);    // Service<ElementPointerProps> — one lazy service per element
interface ElementPointerProps extends PointerProps {
  readonly relativeX: number;          // inside the target's box, from its top-left corner
  readonly relativeY: number;
  readonly relativeProgressX: number;  // over the target's box, 0…1 inside it
  readonly relativeProgressY: number;
}

The relative fields sit beside the viewport ones rather than replacing them, and they are flat, one per axis, with no origin/distance group. The rule that decided it: x must never change meaning depending on how the service was obtained. A superset also means the mixin can default to the element without taking anything away.

The targeted service subscribes to the singleton instead of listening again. One document listener set serves every target, the single-pointerId tracking stays in one place, and reference counting composes — the last targeted subscriber releases the viewport pointer with it.

withPointer therefore targets $el by default, like withInView, withDrag and withScrollProgress:

class Card extends withPointer(Base) {
  moved({ relativeProgressX, relativeProgressY, x, y }) {  }
}

The alternative I rejected

One props shape, always carrying relative fields, with the viewport standing in as the box when no target is given. It removes the overload and the second type, and the mixin's target option then needs no thought at all. It was rejected because without a target relativeX === x and relativeProgressX === progressX: two duplicated fields on every emission of the most frequent source in the framework, which is exactly the "nothing derivable is a field" rule DESIGN §8 spends a paragraph on. The variant that avoids the duplication — letting a target redefine maxX/progressX to be about the element — is worse: one field name silently means two things.

I also rejected a second mixin (withRelativePointer under another name). Two mixins over one hook name is the thing "one method name per mixin" exists to prevent, and the props are a superset anyway, so the second mixin would only differ by its default target.

What the geometry read costs

getBoundingClientRect() is a layout read and a mouse reports up to 1000 events a second, so reading per event is the one thing this must not do. Measured in Chromium, 1000 reads of one element:

situation per read per 1000 events
clean layout, read only 1.7 µs 1.7 ms
a DOM write between reads (forced reflow) 31.6 µs 31.6 ms

The second row is the realistic one: the effect being driven writes to the DOM, which invalidates layout, so every per-event read is a forced reflow. At 1000 Hz that is ~3% of the main thread spent measuring a box that did not move.

So the box is measured on demand and kept until something can have moved it — a scroll captured at the document (every scroller, the page included), a resize, or the target's own ResizeObserver. pointer.spec.ts counts the reads rather than asserting the intent: 100 pointer events cost 1 read, a scroll costs exactly 1 more, and two components on one target still cost 1.

One consequence is deliberate: the cached box is the layout box, so a transform the consumer applies from moved() does not invalidate it and a hover effect cannot feed its own output back in. A box moved by an ongoing transform animation is therefore read in its layout position, and a layout shift with no scroll, no resize and no size change on the target (an image loading above it) is not caught — the same coverage useScrollProgress has.

Specs

In real Chromium, packages/v4/src/services/pointer.spec.ts: coordinates inside a positioned element and outside it, a cold read placed in the box, honest immediate delivery, the read count, a target moved by a page scroll, a target resized under the pointer, reference-counted teardown down to the shared viewport pointer, the mixin on the component root, and two components sharing one target.

Breaking

PointerProps is unchanged and a moved({ x, y }) keeps working. What changes is the mixin surface: withPointer now targets $el, its hook receives ElementPointerProps, and PointerMixinOptions is ServiceMixinOptions<Element> — so a target option must resolve to an element.

🤖 Generated with Claude Code

https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9

@github-actions

github-actions Bot commented Aug 16, 2026

Copy link
Copy Markdown

Export size

Bundled per export with peer dependencies left external, dynamic imports excluded and the output minified; sizes are gzipped.

@studiometa/js-toolkit-v4

Export Size (gzip) Diff
withPointer 2.11 kB +435 B (+25.2%)
usePointer 1.72 kB +430 B (+32.3%)
(barrel) 20.43 kB +282 B (+1.4%)
Unchanged (384)

@studiometa/js-toolkit

Export Size (gzip) Diff
(barrel) 17.44 kB
AbstractService 598 B
Base 9.06 kB
ComponentLoader 2.31 kB
DEFAULT_DIAGNOSTIC_PREFIX 102 B
DragService 2.02 kB
IDLE_TIMEOUT 57 B
KeyService 935 B
LoadService 666 B
MutationService 849 B
PointerService 1.13 kB
RafService 1020 B
ResizeService 1.12 kB
ScrollService 1.36 kB
VISIBLE_ROOT_MARGIN 72 B
autoload 2.4 kB
closestComponent 419 B
composeManifests 119 B
createApp 996 B
defineFeatures 326 B
defineManifest 512 B
fromMetaGlob 228 B
fromWebpackContext 131 B
getClosestParent 197 B
getDirectChildren 202 B
getInstanceFromElement 125 B
getInstances 187 B
getScopedGroups 104 B
importOnInteraction 926 B
importOnMediaQuery 243 B
importWhenIdle 225 B
importWhenPrefersMotion 271 B
importWhenVisible 935 B
isDirectChild 218 B
logTree 551 B
queryComponent 594 B
queryComponentAll 601 B
readEagerTokens 201 B
registerComponent 305 B
registerComponents 356 B
registerManifest 2.87 kB
registerManifests 2.89 kB
useDrag 2.05 kB
useKey 943 B
useLoad 676 B
useMutation 876 B
usePointer 1.15 kB
useRaf 1 kB
useResize 1.13 kB
useScroll 1.36 kB
utils 10.05 kB
utils/Queue 269 B
utils/SmartQueue 440 B
utils/addClass 240 B
utils/addStyle 239 B
utils/animate 3.34 kB
utils/boundingRectToCircle 206 B
utils/cache 208 B
utils/camelCase 405 B
utils/clamp 98 B
utils/clamp01 114 B
utils/collideCircleCircle 129 B
utils/collideCircleRect 192 B
utils/collidePointCircle 128 B
utils/collidePointRect 122 B
utils/collideRectRect 128 B
utils/createEaseInOut 123 B
utils/createEaseOut 91 B
utils/createElement 635 B
utils/createLocalStorage 1.32 kB
utils/createLocalStorageProvider 296 B
utils/createMemoryStorageProvider 174 B
utils/createNoopProvider 128 B
utils/createRange 115 B
utils/createSessionStorage 1.32 kB
utils/createSessionStorageProvider 288 B
utils/createStorage 1.3 kB
utils/createUrlSearchParamsInHashProvider 461 B
utils/createUrlSearchParamsInHashStorage 1.35 kB
utils/createUrlSearchParamsProvider 429 B
utils/createUrlSearchParamsStorage 1.34 kB
utils/damp 106 B
utils/dashCase 404 B
utils/debounce 122 B
utils/domScheduler 310 B
utils/ease 519 B
utils/easeInCirc 285 B
utils/easeInCubic 287 B
utils/easeInExpo 286 B
utils/easeInOutCirc 288 B
utils/easeInOutCubic 289 B
utils/easeInOutExpo 288 B
utils/easeInOutQuad 288 B
utils/easeInOutQuart 289 B
utils/easeInOutQuint 289 B
utils/easeInOutSine 288 B
utils/easeInQuad 285 B
utils/easeInQuart 286 B
utils/easeInQuint 286 B
utils/easeInSine 285 B
utils/easeLinear 77 B
utils/easeOutCirc 286 B
utils/easeOutCubic 288 B
utils/easeOutExpo 286 B
utils/easeOutQuad 286 B
utils/easeOutQuart 286 B
utils/easeOutQuint 286 B
utils/easeOutSine 286 B
utils/endsWith 128 B
utils/fold 168 B
utils/getAncestorWhere 123 B
utils/getAncestorWhereUntil 148 B
utils/getComponentResolver 140 B
utils/getOffsetSizes 194 B
utils/hasWindow 88 B
utils/historyPush 524 B
utils/historyReplace 526 B
utils/inertiaFinalValue 169 B
utils/isArray 63 B
utils/isBoolean 78 B
utils/isDefined 75 B
utils/isDev 78 B
utils/isEmpty 206 B
utils/isEmptyString 108 B
utils/isFunction 79 B
utils/isNull 68 B
utils/isNumber 91 B
utils/isObject 108 B
utils/isString 77 B
utils/keyCodes 122 B
utils/lerp 84 B
utils/loadElement 220 B
utils/loadIframe 241 B
utils/loadImage 241 B
utils/loadLink 237 B
utils/loadScript 251 B
utils/localStorageProvider 839 B
utils/lowerCase 404 B
utils/map 93 B
utils/matrix 136 B
utils/mean 126 B
utils/memo 130 B
utils/memoize 228 B
utils/memoryStorageProvider 843 B
utils/nextFrame 179 B
utils/nextMicrotask 133 B
utils/nextTick 148 B
utils/noop 62 B
utils/noopValue 76 B
utils/objectToURLSearchParams 322 B
utils/pascalCase 407 B
utils/random 93 B
utils/randomInt 113 B
utils/randomItem 234 B
utils/removeClass 242 B
utils/removeStyle 243 B
utils/round 95 B
utils/saveActiveElement 92 B
utils/scrollTo 2.31 kB
utils/sessionStorageProvider 838 B
utils/smoothTo 476 B
utils/snakeCase 406 B
utils/spring 154 B
utils/startsWith 125 B
utils/throttle 125 B
utils/toggleClass 242 B
utils/transform 347 B
utils/transition 1010 B
utils/trapFocus 441 B
utils/tween 1.72 kB
utils/untrapFocus 120 B
utils/upperCase 404 B
utils/urlSearchParamsInHashProvider 845 B
utils/urlSearchParamsProvider 839 B
utils/useScheduler 309 B
utils/wait 103 B
utils/withLeadingCharacters 135 B
utils/withLeadingSlash 142 B
utils/withTrailingCharacters 135 B
utils/withTrailingSlash 142 B
utils/withoutLeadingCharacters 122 B
utils/withoutLeadingCharactersRecursive 165 B
utils/withoutLeadingSlash 133 B
utils/withoutTrailingCharacters 122 B
utils/withoutTrailingCharactersRecursive 165 B
utils/withoutTrailingSlash 133 B
utils/wrap 122 B
version 56 B
withBreakpointManager 1.54 kB
withBreakpointObserver 1.71 kB
withDrag 2.18 kB
withExtraConfig 163 B
withFreezedOptions 187 B
withGroup 455 B
withIntersectionObserver 303 B
withMountOnMediaQuery 393 B
withMountWhenInView 347 B
withMountWhenPrefersMotion 431 B
withMutation 1010 B
withName 109 B
withRelativePointer 1.29 kB
withResponsiveOptions 2.4 kB
withScrolledInView 3.05 kB

@studiometa/js-toolkit-v4

Export Size (gzip) Diff
BREAKPOINTS 776 B
Base 8.09 kB
DIAGNOSTICS 629 B
DRAG_MODES 162 B
EVENTS 155 B
MOUNT_ATTRIBUTE 69 B
SWAP_MODES 129 B
children 243 B
component 10.55 kB
createContext 472 B
createFallbackProvider 1.34 kB
createLocalStorage 2.34 kB
createLocalStorageProvider 1.23 kB
createMemoryStorageProvider 1.23 kB
createService 630 B
createServiceMixin 509 B
createSessionStorage 2.34 kB
createSessionStorageProvider 1.23 kB
createStorage 2.32 kB
createUrlSearchParamsInHashProvider 1.23 kB
createUrlSearchParamsInHashStorage 2.36 kB
createUrlSearchParamsProvider 1.23 kB
createUrlSearchParamsStorage 2.36 kB
defaultScheduler 1.5 kB
defineManifest 983 B
domUpdate 1.23 kB
emitExtendable 1.09 kB
fromMetaGlob 203 B
fromWebpackContext 131 B
getBreakpoints 776 B
getInstances 2.77 kB
inject 175 B
injectContext 675 B
injectContextSync 634 B
jsonSerializer 95 B
localStorageProvider 1.22 kB
memoryStorageProvider 1.23 kB
nextFrame 115 B
on 8.42 kB
perTarget 176 B
provide 178 B
provideContext 704 B
provideRootContext 748 B
read 127 B
registerComponent 10.47 kB
registerComponents 10.48 kB
registerManifest 10.56 kB
sessionStorageProvider 1.22 kB
setBreakpoints 805 B
signal 920 B
subscribeContext 1.45 kB
swap 2.63 kB
toggle 176 B
until 172 B
urlSearchParamsInHashProvider 1.22 kB
urlSearchParamsProvider 1.22 kB
useBreakpoint 1.44 kB
useDrag 3.22 kB
useInView 1.31 kB
useMediaQuery 1.05 kB
useMutation 1.29 kB
usePrefersReducedMotion 1.08 kB
useRaf 1.93 kB
useResize 1.3 kB
useScroll 2.54 kB
useScrollProgress 3.51 kB
useWindowScroll 2.52 kB
useWindowSize 1.3 kB
utils 8.65 kB
utils/DEFAULT_DAMP_FACTOR 109 B
utils/INERTIA_FRAME 97 B
utils/MAX_SPRING_RATIO 100 B
utils/SCROLL_AXES 100 B
utils/TRANSFORM_PROPS 137 B
utils/TRANSITION_OPTIONS 132 B
utils/camelCase 449 B
utils/capitalize 119 B
utils/clamp 133 B
utils/clamp01 149 B
utils/clampDampFactor 157 B
utils/createEaseInOut 120 B
utils/createEaseOut 91 B
utils/createElement 638 B
utils/createRange 205 B
utils/damp 211 B
utils/debounce 121 B
utils/decayOver 162 B
utils/deepmerge 312 B
utils/easeInCirc 94 B
utils/easeInCubic 81 B
utils/easeInExpo 97 B
utils/easeInOutCirc 150 B
utils/easeInOutCubic 141 B
utils/easeInOutExpo 150 B
utils/easeInOutQuad 139 B
utils/easeInOutQuart 140 B
utils/easeInOutQuint 140 B
utils/easeInOutSine 156 B
utils/easeInQuad 80 B
utils/easeInQuart 81 B
utils/easeInQuint 81 B
utils/easeInSine 104 B
utils/easeLinear 77 B
utils/easeOutCirc 121 B
utils/easeOutCubic 111 B
utils/easeOutExpo 124 B
utils/easeOutQuad 110 B
utils/easeOutQuart 112 B
utils/easeOutQuint 111 B
utils/easeOutSine 132 B
utils/enterTransition 679 B
utils/fold 200 B
utils/getOffsetSizes 268 B
utils/historyPush 380 B
utils/historyReplace 381 B
utils/inertiaDecay 199 B
utils/inertiaFinalValue 187 B
utils/inertiaStep 232 B
utils/inertiaTimeConstant 178 B
utils/isBoolean 90 B
utils/isDefined 87 B
utils/isFunction 86 B
utils/isNull 78 B
utils/isNumber 103 B
utils/isObject 115 B
utils/isString 89 B
utils/kebabCase 421 B
utils/leaveTransition 679 B
utils/lerp 120 B
utils/loadImage 245 B
utils/loadLink 776 B
utils/loadScript 697 B
utils/lowerCase 84 B
utils/map 128 B
utils/matrix 150 B
utils/mean 147 B
utils/memo 217 B
utils/noop 62 B
utils/noopValue 76 B
utils/objectToURLSearchParams 253 B
utils/pascalCase 434 B
utils/random 93 B
utils/randomInt 132 B
utils/randomItem 163 B
utils/round 130 B
utils/saveActiveElement 571 B
utils/scrollTo 1.72 kB
utils/selectorFor 2.72 kB
utils/setClassesOrStyles 218 B
utils/smoothTo 2.56 kB
utils/snakeCase 421 B
utils/spring 343 B
utils/throttle 151 B
utils/transform 286 B
utils/transition 577 B
utils/trapFocus 717 B
utils/untrapFocus 587 B
utils/upperCase 84 B
utils/wait 103 B
utils/withLeadingCharacters 142 B
utils/withLeadingSlash 152 B
utils/withTrailingCharacters 143 B
utils/withTrailingSlash 153 B
utils/withoutLeadingCharacters 127 B
utils/withoutLeadingCharactersRecursive 144 B
utils/withoutLeadingSlash 138 B
utils/withoutTrailingCharacters 129 B
utils/withoutTrailingCharactersRecursive 147 B
utils/withoutTrailingSlash 140 B
utils/wrap 154 B
viewTransition 1.66 kB
watchAttributes 1.98 kB
whenDOMSettled 2.31 kB
withDrag 3.61 kB
withInView 1.76 kB
withMutation 1.78 kB
withRaf 2.31 kB
withResize 1.7 kB
withScroll 2.93 kB
withScrollProgress 3.94 kB
write 125 B

@codecov

codecov Bot commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.16%. Comparing base (ee24e48) to head (a5f2630).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #827   +/-   ##
=======================================
  Coverage   97.16%   97.16%           
=======================================
  Files         170      170           
  Lines        4133     4133           
  Branches     1151     1152    +1     
=======================================
  Hits         4016     4016           
  Misses        106      106           
  Partials       11       11           
Flag Coverage Δ
eslint-plugin-js-toolkit 93.79% <ø> (ø)
js-toolkit 97.92% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

Copy link
Copy Markdown

Code Review

Risk: Low — No blocking issues found; the targeted pointer service and mixin changes are safe to merge.

Adds element-scoped pointer services with cached geometry, shared viewport pointer subscriptions, relative coordinate props, and root-targeted mixin behavior. It also exports the new API and adds coverage for lifecycle, geometry caching, invalidation, typing, and shared subscriptions.


Review usage: 98,607 in (80,505 cached) / 1,844 out tokens — $0.0209 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 856664b.

@codspeed-hq

codspeed-hq Bot commented Aug 16, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 15.36%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

❌ 1 regressed benchmark
✅ 140 untouched benchmarks
⏩ 141 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
progress update (5 transforms) 309.6 µs 365.7 µs -15.36%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing feature/v4-relative-pointer (a5f2630) with main (ae3c716)2

Open in CodSpeed

Footnotes

  1. 141 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (a5f2630) during the generation of this report, so ae3c716 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

titouanmathis and others added 2 commits August 16, 2026 11:40
`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<Element>`, 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9
@titouanmathis
titouanmathis force-pushed the feature/v4-relative-pointer branch from 856664b to a5f2630 Compare August 16, 2026 11:41
@titouanmathis
titouanmathis merged commit ae3c716 into main Aug 16, 2026
8 of 9 checks passed
@titouanmathis
titouanmathis deleted the feature/v4-relative-pointer branch August 16, 2026 11:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant