feat(v4): bring back the mutation service - #825
Conversation
Code ReviewRisk: Low — The mutation service and mixin are implemented with lazy, reference-counted observers, canonicalized observation options, lifecycle cleanup, public exports, and coverage for the documented API. No blocking issues were found, so this change is safe to merge. Review usage: 85,801 in (65,712 cached) / 975 out tokens — $0.0188 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit ee02c3e. Previous review runsPrevious run archived 2026-08-16T11:35:45ZCode ReviewRisk: Medium — issues that should be addressed before merge. Adds the public 1 issue found:
Review usage: 77,580 in (42,681 cached) / 1,047 out tokens — $0.0271 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit 94d4806. |
Export sizeBundled per export with peer dependencies left external, dynamic imports excluded and the output minified; sizes are gzipped. @studiometa/js-toolkit-v4
Unchanged (378)@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 #825 +/- ##
=======================================
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:
|
Merging this PR will improve performance by 44.99%
Performance Changes
Tip Curious why this is faster? Comment Comparing Footnotes
|
v3 shipped `MutationService`/`useMutation`, v4 dropped them, and mutation
handling stayed internal to the registry — whose observer is deliberately
filtered to the attributes the framework can name. Anything else meant
writing a `MutationObserver` by hand, which `@studiometa/ui` does for
`Disclosure`. `useMutation(target, init?)` brings the capability back as a
public, lazy, reference-counted service.
Three decisions differ from the v3 shape.
The props are `{ records }`, not v3's `{ mutations }`, and the service keeps
nothing after the delivery. A `childList` record holds the nodes it removed,
so retaining the last batch — as v3 did, in a props object that outlived
every emission — keeps a detached subtree alive for the life of the page.
The batch is therefore valid for the call only, which also makes
`hasProps()` honest: a batch is an event, not a state, so `props()` is empty
between deliveries and `{ immediate: true }` waits for a real mutation, the
same argument the frame tick already makes.
The key is a canonical init rather than `JSON.stringify(options)`. Property
order, a repeated or unsorted `attributeFilter`, and the platform's own
`attributeOldValue`/`characterDataOldValue` inferences all describe one
observation, and each of them used to buy a second observer. Contradictory
options are forwarded untouched so `observe()` still rejects them.
The default observation is `{ childList: true, subtree: true }` instead of
v3's `{ attributes: true }`, because attributes of one element are
`watchAttributes()`'s job and the subtree is the case only this service
covers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9
`withMutation()` binds `mutated()` to a mount cycle over `useMutation()`, the way `withInView()` binds `intersected()`. The target defaults to `this.$el`, so `withMutation(Base)` watches the component's own subtree and the mixin stays sugar for the default case; any other node is the `target` option or an explicit subscription in `mounted()`. It does not default `immediate` to `true` as `withInView()` does. A batch describes a mutation that happened rather than a state that holds, so the service has no current props between deliveries and an immediate subscription would have nothing honest to deliver. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9
Section 8 listed the six services and section 3 said mutation handling belonged to the registry, which the public service now contradicts. Both sections point at each other instead: `watchAttributes()` for an attribute, the registry's own filtered observer for what the framework reconciles, and `useMutation()` for everything else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9
94d4806 to
ee02c3e
Compare
v3 shipped
MutationService/useMutation/withMutation; v4 dropped them and kept mutation handling inside the registry. That left a real gap: the registry's observer is deliberately filtered to the attributes the framework can name, so anything else means writing aMutationObserverby hand — which@studiometa/uidoes today inDisclosure:This PR brings the capability back as a public service with a matching mixin, built on
createService()/perTarget()/createServiceMixin()likeuseInView(). Nothing is kept from the v3 shape except the names.The props shape
{ records: readonly MutationRecord[] }— one field, and the service keeps nothing after the delivery.v3 published
{ mutations: MutationRecord[] }from a props object that outlived every emission. AchildListrecord holds the nodes it removed, so retaining the last batch keeps a detached subtree alive for the life of the page — the exact reasondom-mutations.tsrefuses to queue records before a processor exists. Here the batch is valid for the duration of the call only, andprops.recordsgoes back to a frozen empty array as soon asemit()returns. A subscriber that wants to keep one copies it, as with every other service.That also settles
hasProps(): a batch is a mutation that happened, not a state that holds.hasProps()is true only while a delivery is in flight, soprops()is empty between deliveries and{ immediate: true }waits for a real mutation — the same argument section 8 already makes for the frame tick, and the reasonwithMutation()does not defaultimmediatetotruethe waywithInView()does.Nothing else is a field, per the "nothing derivable" rule:
addedNodes,hasChildListChangesand friends are all reads ofrecords. FlatteningMutationRecorditself into per-record props was considered and rejected — it would re-implement a platform type and destroy the batch, so the one real call site would re-resolve its group once per added node instead of once per batch. No new closed string set was introduced either: the only one in play,MutationRecord.type, belongs to the DOM.The keying
perTarget()with a canonical init instead of the raw options object.MutationObserverInitis plain data, so it needs no equivalent ofuseInView()'s root ids, but the naiveJSON.stringify(options)buys a second observer for options that describe one observation:{ childList, subtree }vs{ subtree, childList };attributeFilter— a filter is a set;attributeOldValueandattributeFilterimplyattributes,characterDataOldValueimpliescharacterData.resolveInit()normalises all three and every field is spelled out in a fixed order, soJSON.stringify()of the result is canonical and the observer is handed exactly what the key describes. Contradictory options — anattributeFilterwithattributes: false— are forwarded untouched soobserve()still rejects them rather than being silently reinterpreted.The default observation, when the caller names none, is
{ childList: true, subtree: true }rather than v3's{ attributes: true }. See below.How it relates to
watchAttributes()and the registry's observerThree tools, and the docs say which to reach for:
dom-mutations.ts) owns component discovery, lifecycle, mount strategies, ref invalidation and declared options. This PR does not touch it, does not read its queue and does not extend its filter.watchAttributes(el, callback)answers "what did this attribute become": every attribute of one element, coalesced to one change per attribute per batch, reported after component lifecycle has settled, through the shared queue.useMutation(node, init?)is the general-purpose observer for everything neither covers — a subtree, character data, a node the framework knows nothing about. It delivers raw records on the platform's own timing; a subscriber that needs the framework's ordering awaitswhenDOMSettled()from its callback.Is
watchAttributes()now redundant? No, and I have deliberately not touched it.useMutation(el, { attributes: true, attributeOldValue: true })can observe the same attributes, but it gives you none of what makes that helper useful: no coalescing (two writes in one batch are two records, and a rewrite ending where it started is still a record), no final-DOM-value semantics, and — the part that cannot be rebuilt from outside — no ordering guarantee against component lifecycle, because its records reach the shared queue and this service's do not.data-on:<event>rebinding after a morph needs exactly those three. The overlap is the raw capability, not the contract. If the owner wants them merged, the honest direction is the reverse of redundancy: give the service an opt-in into the shared queue. That is a separate change and is not proposed here.Surface and checks
packages/v4/src/services/mutation.tsand its spec, exported fromsrc/index.tswith generated./useMutationand./withMutationsubpaths.exports.spec.tsand the packed-package node consumer both assert the new names and move from 79 to 81 root exports.MutationObserverend-to-end test beside the faked ones.npm run lint,npm run lint:types,npm run test:v4(989 passing) andnpm run check:packageare green on each of the three commits.🤖 Generated with Claude Code
https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9