feat(v4)!: element-relative pointer coordinates, at the service level - #827
Conversation
Export sizeBundled per export with peer dependencies left external, dynamic imports excluded and the output minified; sizes are gzipped. @studiometa/js-toolkit-v4
Unchanged (384)@studiometa/js-toolkit
@studiometa/js-toolkit-v4
|
Codecov Report✅ All modified and coverable lines are covered by tests. 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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Code ReviewRisk: 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. |
Merging this PR will degrade performance by 15.36%
|
| 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
Footnotes
-
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. ↩
-
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. ↩
`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
856664b to
a5f2630
Compare
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 callsgetBoundingClientRect()in a component.The API
The relative fields sit beside the viewport ones rather than replacing them, and they are flat, one per axis, with no
origin/distancegroup. The rule that decided it:xmust 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-
pointerIdtracking stays in one place, and reference counting composes — the last targeted subscriber releases the viewport pointer with it.withPointertherefore targets$elby default, likewithInView,withDragandwithScrollProgress: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
targetoption then needs no thought at all. It was rejected because without a targetrelativeX === xandrelativeProgressX === 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 redefinemaxX/progressXto be about the element — is worse: one field name silently means two things.I also rejected a second mixin (
withRelativePointerunder 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: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
scrollcaptured at the document (every scroller, the page included), aresize, or the target's ownResizeObserver.pointer.spec.tscounts 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 coverageuseScrollProgresshas.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, honestimmediatedelivery, 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
PointerPropsis unchanged and amoved({ x, y })keeps working. What changes is the mixin surface:withPointernow targets$el, its hook receivesElementPointerProps, andPointerMixinOptionsisServiceMixinOptions<Element>— so atargetoption must resolve to an element.🤖 Generated with Claude Code
https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9