From 588e65914e501ed78fd91f0604dc9201798c07db Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Sun, 16 Aug 2026 10:26:14 +0000 Subject: [PATCH 1/3] feat(v4): add a mutation service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9 --- packages/v4/package.json | 4 + packages/v4/src/exports.spec.ts | 14 +- packages/v4/src/index.ts | 1 + packages/v4/src/services/mutation.spec.ts | 321 ++++++++++++++++++++++ packages/v4/src/services/mutation.ts | 111 ++++++++ packages/v4/src/subpaths/useMutation.ts | 1 + packages/v4/test/package-node-consumer.js | 5 +- 7 files changed, 455 insertions(+), 2 deletions(-) create mode 100644 packages/v4/src/services/mutation.spec.ts create mode 100644 packages/v4/src/services/mutation.ts create mode 100644 packages/v4/src/subpaths/useMutation.ts diff --git a/packages/v4/package.json b/packages/v4/package.json index da1689b5d..c83046bee 100644 --- a/packages/v4/package.json +++ b/packages/v4/package.json @@ -192,6 +192,10 @@ "types": "./dist/subpaths/usePrefersReducedMotion.d.ts", "import": "./dist/subpaths/usePrefersReducedMotion.js" }, + "./useMutation": { + "types": "./dist/subpaths/useMutation.d.ts", + "import": "./dist/subpaths/useMutation.js" + }, "./usePointer": { "types": "./dist/subpaths/usePointer.d.ts", "import": "./dist/subpaths/usePointer.js" diff --git a/packages/v4/src/exports.spec.ts b/packages/v4/src/exports.spec.ts index 76118a796..f0c0bb45e 100644 --- a/packages/v4/src/exports.spec.ts +++ b/packages/v4/src/exports.spec.ts @@ -13,6 +13,7 @@ import { subscribeContext, useDrag, useInView, + useMutation, useScrollProgress, watchAttributes, withDrag, @@ -30,6 +31,7 @@ import { type ExtendableDetail, type Extension, type InViewProps, + type MutationProps, type AttributeChange, type AttributeWatcher, type ContextCallback, @@ -56,6 +58,9 @@ import useDragFromSubpath from '@studiometa/js-toolkit-v4/useDrag'; import useInViewFromSubpath, { useInView as namedUseInViewFromSubpath, } from '@studiometa/js-toolkit-v4/useInView'; +import useMutationFromSubpath, { + useMutation as namedUseMutationFromSubpath, +} from '@studiometa/js-toolkit-v4/useMutation'; import useScrollProgressSubpath from '@studiometa/js-toolkit-v4/useScrollProgress'; import watchAttributesFromSubpath, { watchAttributes as namedWatchAttributesFromSubpath, @@ -130,7 +135,7 @@ describe('the package entry points', () => { it('keeps the framework on the root entry, without the utils or removed exports', async () => { expect(typeof Base).toBe('function'); const root = (await import('@studiometa/js-toolkit-v4')) as Record; - expect(Object.keys(root)).toHaveLength(79); + expect(Object.keys(root)).toHaveLength(80); expect(root.clamp).toBeUndefined(); expect(root.smoothTo).toBeUndefined(); for (const removed of [ @@ -224,6 +229,13 @@ describe('the package entry points', () => { expectTypeOf().toMatchTypeOf(); }); + it('serves useMutation from the root and its symbol subpath', () => { + expect(useMutationFromSubpath).toBe(useMutation); + expect(namedUseMutationFromSubpath).toBe(useMutation); + expectTypeOf(useMutation(document)).toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf<{ readonly records: readonly MutationRecord[] }>(); + }); + 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 07057b521..bbfe9dae7 100644 --- a/packages/v4/src/index.ts +++ b/packages/v4/src/index.ts @@ -112,6 +112,7 @@ export { type ServiceMixinOptions, } from './services/mixin.js'; export { useMediaQuery, usePrefersReducedMotion, type MediaQueryProps } from './services/media.js'; +export { useMutation, type MutationProps } from './services/mutation.js'; export { usePointer, withPointer, diff --git a/packages/v4/src/services/mutation.spec.ts b/packages/v4/src/services/mutation.spec.ts new file mode 100644 index 000000000..bb8ec74b8 --- /dev/null +++ b/packages/v4/src/services/mutation.spec.ts @@ -0,0 +1,321 @@ +import { afterEach, beforeEach, describe, expect, expectTypeOf, it } from 'vitest'; +import { DIAGNOSTICS, type ToolkitDiagnosticDetail } from '../diagnostic-contract.js'; +import { EVENTS } from '../events.js'; +import { useMutation, type MutationProps } from './mutation.js'; +import type { Service } from './service.js'; + +interface Observation { + target: Node; + init: MutationObserverInit; +} + +class FakeMutationObserver { + static instances: FakeMutationObserver[] = []; + + readonly observed: Observation[] = []; + disconnects = 0; + + constructor(readonly callback: MutationCallback) { + FakeMutationObserver.instances.push(this); + } + + observe(target: Node, init: MutationObserverInit = {}): void { + this.observed.push({ target, init }); + } + + disconnect(): void { + this.disconnects += 1; + } + + takeRecords(): MutationRecord[] { + return []; + } + + deliver(records: MutationRecord[]): void { + this.callback(records, this as unknown as MutationObserver); + } +} + +const NativeMutationObserver = globalThis.MutationObserver; + +/** + * Core keeps its own document observer, which the fake constructor also + * captures, so tests match observers by what they observe. + */ +function observersOf(target: Node): FakeMutationObserver[] { + return FakeMutationObserver.instances.filter(({ observed }) => + observed.some((observation) => observation.target === target), + ); +} + +function observerOf(target: Node): FakeMutationObserver { + const [observer] = observersOf(target); + expect(observer).toBeDefined(); + return observer as FakeMutationObserver; +} + +function initOf(target: Node): MutationObserverInit | undefined { + return observerOf(target).observed.find((observation) => observation.target === target)?.init; +} + +/** The canonical form every resolved init is compared against. */ +function resolvedInit(overrides: MutationObserverInit = {}): MutationObserverInit { + return { + childList: false, + subtree: false, + attributes: false, + attributeOldValue: false, + characterData: false, + characterDataOldValue: false, + ...overrides, + }; +} + +function recordFor(target: Node, type: MutationRecordType = 'childList'): MutationRecord { + const empty = document.createElement('div').childNodes; + return { + type, + target, + addedNodes: empty, + removedNodes: empty, + previousSibling: null, + nextSibling: null, + attributeName: null, + attributeNamespace: null, + oldValue: null, + }; +} + +function catchDiagnostics(run: () => void): ToolkitDiagnosticDetail[] { + const diagnostics: ToolkitDiagnosticDetail[] = []; + const onDiagnostic = (event: Event) => { + event.preventDefault(); + diagnostics.push((event as CustomEvent).detail); + }; + document.addEventListener(EVENTS.diagnostic, onDiagnostic); + try { + run(); + } finally { + document.removeEventListener(EVENTS.diagnostic, onDiagnostic); + } + return diagnostics; +} + +function readonlyAssertions(props: MutationProps): void { + // @ts-expect-error service props belong to the service + props.records = []; + // @ts-expect-error the batch is readonly too + props.records.push(recordFor(document.body)); +} +void readonlyAssertions; + +describe('useMutation', () => { + beforeEach(() => { + FakeMutationObserver.instances = []; + globalThis.MutationObserver = FakeMutationObserver as unknown as typeof MutationObserver; + }); + + afterEach(() => { + globalThis.MutationObserver = NativeMutationObserver; + document.body.innerHTML = ''; + }); + + it('is lazy and watches the subtree of its target by default', () => { + const target = document.createElement('div'); + const service = useMutation(target); + + expectTypeOf(service).toEqualTypeOf>(); + expect(observersOf(target)).toEqual([]); + + const unsubscribe = service.subscribe(() => {}); + expect(observersOf(target)).toHaveLength(1); + expect(initOf(target)).toEqual(resolvedInit({ childList: true, subtree: true })); + + unsubscribe(); + }); + + it('publishes one batch and keeps nothing after the delivery', () => { + const target = document.createElement('div'); + const service = useMutation(target); + const seen: Array = []; + const unsubscribe = service.subscribe(({ records }) => seen.push(records)); + + const record = recordFor(target); + observerOf(target).deliver([record]); + + // A subscriber that retains the batch keeps it; the service does not. + expect(seen).toEqual([[record]]); + expect(service.props().records).toEqual([]); + + unsubscribe(); + }); + + it('has no current props between two batches, so immediate waits', () => { + const target = document.createElement('div'); + const service = useMutation(target); + const early: MutationProps[] = []; + const unsubscribeEarly = service.subscribe((props) => early.push(props), { immediate: true }); + + expect(early).toEqual([]); + + observerOf(target).deliver([recordFor(target)]); + expect(early).toHaveLength(1); + + const late: MutationProps[] = []; + const unsubscribeLate = service.subscribe((props) => late.push(props), { immediate: true }); + expect(late).toEqual([]); + + unsubscribeEarly(); + unsubscribeLate(); + }); + + it('serves two subscribers of one target and observation from one observer', () => { + const target = document.createElement('div'); + const first = useMutation(target, { childList: true }); + const second = useMutation(target, { childList: true }); + expect(first).toBe(second); + + const seen: string[] = []; + const unsubscribeFirst = first.subscribe(() => seen.push('first')); + const unsubscribeSecond = second.subscribe(() => seen.push('second')); + expect(observersOf(target)).toHaveLength(1); + + observerOf(target).deliver([recordFor(target)]); + expect(seen).toEqual(['first', 'second']); + + unsubscribeFirst(); + unsubscribeSecond(); + }); + + it('keys observationally identical options to one service', () => { + const target = document.createElement('div'); + + // Property order, and the platform's own `attributes` inference. + expect(useMutation(target, { childList: true, subtree: true })).toBe( + useMutation(target, { subtree: true, childList: true }), + ); + expect(useMutation(target, { attributeOldValue: true })).toBe( + useMutation(target, { attributes: true, attributeOldValue: true }), + ); + expect(useMutation(target, { characterDataOldValue: true })).toBe( + useMutation(target, { characterData: true, characterDataOldValue: true }), + ); + // A filter is a set, so its written order and repeats do not matter. + expect(useMutation(target, { attributeFilter: ['b', 'a', 'b'] })).toBe( + useMutation(target, { attributeFilter: ['a', 'b'] }), + ); + // An omitted init is the documented default, spelled out. + expect(useMutation(target)).toBe(useMutation(target, { childList: true, subtree: true })); + }); + + it('keeps one observer per target and observation', () => { + const target = document.createElement('div'); + const other = document.createElement('div'); + + const children = useMutation(target, { childList: true }); + const deepChildren = useMutation(target, { childList: true, subtree: true }); + const filtered = useMutation(target, { attributeFilter: ['data-state'] }); + const elsewhere = useMutation(other, { childList: true }); + + expect(children).not.toBe(deepChildren); + expect(children).not.toBe(filtered); + expect(children).not.toBe(elsewhere); + + const unsubscribes = [children, deepChildren, filtered, elsewhere].map((service) => + service.subscribe(() => {}), + ); + expect(observersOf(target)).toHaveLength(3); + expect(observersOf(other)).toHaveLength(1); + expect(observersOf(target).map(({ observed }) => observed[0]?.init)).toEqual([ + resolvedInit({ childList: true }), + resolvedInit({ childList: true, subtree: true }), + resolvedInit({ attributes: true, attributeFilter: ['data-state'] }), + ]); + + for (const unsubscribe of unsubscribes) unsubscribe(); + }); + + it('disconnects on the last unsubscribe and observes again on restart', () => { + const target = document.createElement('div'); + const service = useMutation(target); + const unsubscribeFirst = service.subscribe(() => {}); + const unsubscribeSecond = service.subscribe(() => {}); + const observer = observerOf(target); + + unsubscribeFirst(); + expect(observer.disconnects).toBe(0); + unsubscribeSecond(); + expect(observer.disconnects).toBe(1); + + const seen: number[] = []; + const unsubscribeRestarted = service.subscribe(({ records }) => seen.push(records.length)); + expect(observersOf(target)).toHaveLength(2); + + // The released run must not publish into the one that replaced it. + observer.deliver([recordFor(target)]); + expect(seen).toEqual([]); + + observersOf(target)[1]?.deliver([recordFor(target)]); + expect(seen).toEqual([1]); + + unsubscribeRestarted(); + expect(observersOf(target)[1]?.disconnects).toBe(1); + }); + + it('keeps serving the other subscribers when one throws', () => { + const target = document.createElement('div'); + const service = useMutation(target); + let reached = 0; + + const unsubscribeFailing = service.subscribe(() => { + throw new Error('boom'); + }); + const unsubscribeHealthy = service.subscribe(() => { + reached += 1; + }); + + const diagnostics = catchDiagnostics(() => { + observerOf(target).deliver([recordFor(target)]); + observerOf(target).deliver([recordFor(target)]); + }); + + expect(reached).toBe(2); + expect(diagnostics).toHaveLength(2); + expect(diagnostics.every(({ code }) => code === DIAGNOSTICS.callback.serviceFailed)).toBe(true); + + unsubscribeFailing(); + unsubscribeHealthy(); + }); +}); + +describe('useMutation on the platform observer', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('reports real records for the observation it was given', async () => { + const target = document.createElement('section'); + document.body.append(target); + + const seen: MutationRecord[] = []; + const unsubscribe = useMutation(target).subscribe(({ records }) => seen.push(...records)); + + const child = document.createElement('p'); + target.append(child); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + expect(seen).toHaveLength(1); + expect(seen[0]?.type).toBe('childList'); + expect(seen[0]?.target).toBe(target); + expect([...(seen[0]?.addedNodes ?? [])]).toEqual([child]); + + unsubscribe(); + target.append(document.createElement('p')); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + expect(seen).toHaveLength(1); + }); +}); diff --git a/packages/v4/src/services/mutation.ts b/packages/v4/src/services/mutation.ts new file mode 100644 index 000000000..dc8e687aa --- /dev/null +++ b/packages/v4/src/services/mutation.ts @@ -0,0 +1,111 @@ +import { getSharedRuntimeSlot } from '../shared-runtime.js'; +import { createService, perTarget, type MutableProps, type Service } from './service.js'; + +export interface MutationProps { + /** + * The batch the observer just delivered, in observation order. + * + * It is valid for the duration of the call only: the service drops it + * afterwards, because a `childList` record holds the nodes it removed and + * retaining the last batch would keep a detached subtree alive for the life + * of the page. `[...records]` is how you keep one. + */ + readonly records: readonly MutationRecord[]; +} + +/** The resting value: a batch is an event, so there is none between deliveries. */ +const NO_RECORDS: readonly MutationRecord[] = /* @__PURE__ */ Object.freeze([]); + +/** + * Watch the target's subtree structure when the caller names no observation. + * Attributes of one element are `watchAttributes()`'s job, so the default is + * the case only this service covers. + */ +const DEFAULT_INIT: MutationObserverInit = /* @__PURE__ */ Object.freeze({ + childList: true, + subtree: true, +}); + +/** + * Give observationally identical options one canonical form, so they resolve + * to one observer whatever order or shorthand the caller wrote them in. + * + * The two inferences are the platform's own: `attributeOldValue` and + * `attributeFilter` imply `attributes`, and `characterDataOldValue` implies + * `characterData`. Contradictory options — a filter with `attributes: false` — + * are forwarded untouched so `observe()` still rejects them. + */ +function resolveInit(init: MutationObserverInit): MutationObserverInit { + const attributeFilter = init.attributeFilter && [...new Set(init.attributeFilter)].sort(); + return { + childList: init.childList ?? false, + subtree: init.subtree ?? false, + attributes: + init.attributes ?? (init.attributeOldValue !== undefined || attributeFilter !== undefined), + attributeOldValue: init.attributeOldValue ?? false, + characterData: init.characterData ?? init.characterDataOldValue !== undefined, + characterDataOldValue: init.characterDataOldValue ?? false, + ...(attributeFilter !== undefined && { attributeFilter }), + }; +} + +/** Key a resolved init. Its property order is fixed, so the string is canonical. */ +function keyOf(init: MutationObserverInit): string { + return JSON.stringify(init); +} + +function createMutationService(target: Node, init: MutationObserverInit): Service { + const props: MutableProps = { records: NO_RECORDS }; + let isDelivering = false; + + return createService({ + props: () => props, + // Between two batches there is nothing current to hand over, so + // `{ immediate: true }` waits for a real mutation. + hasProps: () => isDelivering, + start(emit) { + let isActive = true; + const observer = new MutationObserver((records) => { + // A callback queued before the last subscriber left must not publish + // into the run that replaced it. + if (!isActive) { + return; + } + props.records = records; + isDelivering = true; + emit(props); + isDelivering = false; + props.records = NO_RECORDS; + }); + observer.observe(target, init); + + return () => { + isActive = false; + observer.disconnect(); + }; + }, + }); +} + +const mutationServices = /* @__PURE__ */ getSharedRuntimeSlot('service:mutation', 1, () => + perTarget(createMutationService, keyOf), +); + +/** + * Observe DOM mutations through one lazy service per target and observation. + * + * This is the general-purpose observer: any node, any `MutationObserverInit`, + * delivered on the platform's own timing. Two narrower tools come first. + * `watchAttributes(el, callback)` reports one coalesced change per attribute + * of one element, after component lifecycle has settled — reach for it + * whenever the question is "what did this attribute become". The registry's + * internal observer handles component discovery and declared options; nothing + * here replaces it, and a subscriber that needs the framework's own ordering + * awaits `whenDOMSettled()` from its callback. + */ +export function useMutation( + target: Node, + init: MutationObserverInit = DEFAULT_INIT, +): Service { + return mutationServices(target, resolveInit(init)); +} diff --git a/packages/v4/src/subpaths/useMutation.ts b/packages/v4/src/subpaths/useMutation.ts new file mode 100644 index 000000000..2f984cf1f --- /dev/null +++ b/packages/v4/src/subpaths/useMutation.ts @@ -0,0 +1 @@ +export { useMutation, useMutation as default } from '../services/mutation.js'; diff --git a/packages/v4/test/package-node-consumer.js b/packages/v4/test/package-node-consumer.js index a9a3d8446..72b70f7c6 100644 --- a/packages/v4/test/package-node-consumer.js +++ b/packages/v4/test/package-node-consumer.js @@ -8,6 +8,7 @@ import emitExtendableDefault, { emitExtendable } from '@studiometa/js-toolkit-v4 import subscribeContextDefault, { subscribeContext, } from '@studiometa/js-toolkit-v4/subscribeContext'; +import useMutationDefault, { useMutation } from '@studiometa/js-toolkit-v4/useMutation'; import useRafDefault, { useRaf } from '@studiometa/js-toolkit-v4/useRaf'; import watchAttributesDefault, { watchAttributes } from '@studiometa/js-toolkit-v4/watchAttributes'; import createStorageDefault, { createStorage } from '@studiometa/js-toolkit-v4/createStorage'; @@ -40,6 +41,8 @@ assert.equal(subscribeContext, toolkit.subscribeContext); assert.equal(subscribeContextDefault, subscribeContext); assert.equal(useRaf, toolkit.useRaf); assert.equal(useRafDefault, useRaf); +assert.equal(useMutation, toolkit.useMutation); +assert.equal(useMutationDefault, useMutation); assert.equal(watchAttributes, toolkit.watchAttributes); assert.equal(watchAttributesDefault, watchAttributes); assert.equal('$watchAttributes' in Base.prototype, false); @@ -47,7 +50,7 @@ assert.equal(createStorage, toolkit.createStorage); assert.equal(createStorageDefault, createStorage); assert.equal(createMemoryStorageProvider, toolkit.createMemoryStorageProvider); assert.equal(createMemoryStorageProviderDefault, createMemoryStorageProvider); -assert.equal(Object.keys(toolkit).length, 79); +assert.equal(Object.keys(toolkit).length, 80); assert.equal(toolkit.ToolkitErrorDetail, undefined); assert.equal(toolkit.ToolkitErrorStage, undefined); From fc2829ef71d432ca241ba1718dd0ea4bf82d8ee8 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Sun, 16 Aug 2026 10:27:17 +0000 Subject: [PATCH 2/3] feat(v4): add the withMutation mixin `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) Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9 --- packages/v4/package.json | 4 + packages/v4/src/exports.spec.ts | 16 +- packages/v4/src/index.ts | 8 +- packages/v4/src/services/mutation.spec.ts | 175 +++++++++++++++++++++- packages/v4/src/services/mutation.ts | 43 ++++++ packages/v4/src/subpaths/withMutation.ts | 1 + packages/v4/test/package-node-consumer.js | 5 +- 7 files changed, 247 insertions(+), 5 deletions(-) create mode 100644 packages/v4/src/subpaths/withMutation.ts diff --git a/packages/v4/package.json b/packages/v4/package.json index c83046bee..7e2e945bb 100644 --- a/packages/v4/package.json +++ b/packages/v4/package.json @@ -196,6 +196,10 @@ "types": "./dist/subpaths/useMutation.d.ts", "import": "./dist/subpaths/useMutation.js" }, + "./withMutation": { + "types": "./dist/subpaths/withMutation.d.ts", + "import": "./dist/subpaths/withMutation.js" + }, "./usePointer": { "types": "./dist/subpaths/usePointer.d.ts", "import": "./dist/subpaths/usePointer.js" diff --git a/packages/v4/src/exports.spec.ts b/packages/v4/src/exports.spec.ts index f0c0bb45e..b0b55837b 100644 --- a/packages/v4/src/exports.spec.ts +++ b/packages/v4/src/exports.spec.ts @@ -18,6 +18,7 @@ import { watchAttributes, withDrag, withInView, + withMutation, withScrollProgress, type DefineManifestOptions, type DomMutation, @@ -31,6 +32,8 @@ import { type ExtendableDetail, type Extension, type InViewProps, + type MutationHook, + type MutationMixinOptions, type MutationProps, type AttributeChange, type AttributeWatcher, @@ -77,6 +80,9 @@ import withDragFromSubpath from '@studiometa/js-toolkit-v4/withDrag'; import withInViewFromSubpath, { withInView as namedWithInViewFromSubpath, } from '@studiometa/js-toolkit-v4/withInView'; +import withMutationFromSubpath, { + withMutation as namedWithMutationFromSubpath, +} from '@studiometa/js-toolkit-v4/withMutation'; import withScrollProgressSubpath from '@studiometa/js-toolkit-v4/withScrollProgress'; function toolkitDiagnosticDetailTypeAssertions(detail: ToolkitDiagnosticDetail): void { @@ -135,7 +141,7 @@ describe('the package entry points', () => { it('keeps the framework on the root entry, without the utils or removed exports', async () => { expect(typeof Base).toBe('function'); const root = (await import('@studiometa/js-toolkit-v4')) as Record; - expect(Object.keys(root)).toHaveLength(80); + expect(Object.keys(root)).toHaveLength(81); expect(root.clamp).toBeUndefined(); expect(root.smoothTo).toBeUndefined(); for (const removed of [ @@ -229,11 +235,17 @@ describe('the package entry points', () => { expectTypeOf().toMatchTypeOf(); }); - it('serves useMutation from the root and its symbol subpath', () => { + it('serves useMutation and withMutation from the root and their symbol subpaths', () => { expect(useMutationFromSubpath).toBe(useMutation); expect(namedUseMutationFromSubpath).toBe(useMutation); + expect(withMutationFromSubpath).toBe(withMutation); + expect(namedWithMutationFromSubpath).toBe(withMutation); expectTypeOf(useMutation(document)).toEqualTypeOf>(); expectTypeOf().toEqualTypeOf<{ readonly records: readonly MutationRecord[] }>(); + expectTypeOf().toMatchTypeOf<{ + mutated?: (props: MutationProps) => void; + }>(); + expectTypeOf().toMatchTypeOf(); }); it('exports manifest generation from the root and symbol subpaths', () => { diff --git a/packages/v4/src/index.ts b/packages/v4/src/index.ts index bbfe9dae7..847a3303a 100644 --- a/packages/v4/src/index.ts +++ b/packages/v4/src/index.ts @@ -112,7 +112,13 @@ export { type ServiceMixinOptions, } from './services/mixin.js'; export { useMediaQuery, usePrefersReducedMotion, type MediaQueryProps } from './services/media.js'; -export { useMutation, type MutationProps } from './services/mutation.js'; +export { + useMutation, + withMutation, + type MutationHook, + type MutationMixinOptions, + type MutationProps, +} from './services/mutation.js'; export { usePointer, withPointer, diff --git a/packages/v4/src/services/mutation.spec.ts b/packages/v4/src/services/mutation.spec.ts index bb8ec74b8..2137603c7 100644 --- a/packages/v4/src/services/mutation.spec.ts +++ b/packages/v4/src/services/mutation.spec.ts @@ -1,8 +1,16 @@ import { afterEach, beforeEach, describe, expect, expectTypeOf, it } from 'vitest'; +import { Base } from '../Base.js'; import { DIAGNOSTICS, type ToolkitDiagnosticDetail } from '../diagnostic-contract.js'; import { EVENTS } from '../events.js'; -import { useMutation, type MutationProps } from './mutation.js'; +import { + useMutation, + withMutation, + type MutationHook, + type MutationMixinOptions, + type MutationProps, +} from './mutation.js'; import type { Service } from './service.js'; +import type { Toggle } from './toggle.js'; interface Observation { target: Node; @@ -109,6 +117,33 @@ function readonlyAssertions(props: MutationProps): void { } void readonlyAssertions; +class TypedWatcher extends withMutation(Base, { manual: true }) { + mutated(_props: MutationProps): void {} +} + +function mixinTypeAssertions(instance: TypedWatcher): void { + expectTypeOf().toMatchTypeOf<{ + mutated?: (props: MutationProps) => void; + }>(); + expectTypeOf(instance.mutated).toEqualTypeOf<(props: MutationProps) => void>(); + expectTypeOf(instance.$services.mutated).toEqualTypeOf(); + instance.$services.mutated.start(); + instance.$services.mutated.stop(); + // @ts-expect-error only the mixin's fixed hook gets a service handle + instance.$services.scrolled.start(); +} +void mixinTypeAssertions; + +const mixinOptionsTypeAssertions: MutationMixinOptions = { + childList: true, + subtree: true, + attributeFilter: ['data-state'], + manual: true, + immediate: false, + target: () => document, +}; +void mixinOptionsTypeAssertions; + describe('useMutation', () => { beforeEach(() => { FakeMutationObserver.instances = []; @@ -319,3 +354,141 @@ describe('useMutation on the platform observer', () => { expect(seen).toHaveLength(1); }); }); + +describe('withMutation', () => { + beforeEach(() => { + FakeMutationObserver.instances = []; + globalThis.MutationObserver = FakeMutationObserver as unknown as typeof MutationObserver; + }); + + afterEach(() => { + globalThis.MutationObserver = NativeMutationObserver; + document.body.innerHTML = ''; + }); + + it('supports the no-build mixin form and watches the component root', () => { + const seen: number[] = []; + + class Watcher extends withMutation(Base) { + mutated({ records }: MutationProps): void { + seen.push(records.length); + } + } + + const el = document.createElement('article'); + document.body.append(el); + const instance = new Watcher(el).$mount(); + + expect(initOf(el)).toEqual(resolvedInit({ childList: true, subtree: true })); + + observerOf(el).deliver([recordFor(el)]); + expect(seen).toEqual([1]); + instance.$terminate(); + }); + + it('supports the stage-3 decorator form', () => { + const seen: number[] = []; + + @withMutation({ attributeFilter: ['data-state'] }) + class Watcher extends Base { + mutated({ records }: MutationProps): void { + seen.push(records.length); + } + } + + const el = document.createElement('article'); + document.body.append(el); + const instance = new Watcher(el).$mount(); + + expect(initOf(el)).toEqual(resolvedInit({ attributes: true, attributeFilter: ['data-state'] })); + observerOf(el).deliver([recordFor(el, 'attributes')]); + expect(seen).toEqual([1]); + instance.$terminate(); + }); + + it('resolves a custom target and forwards only MutationObserverInit fields', () => { + class Watcher extends withMutation(Base, { + childList: true, + characterData: true, + subtree: true, + target: (instance) => instance.$el.firstElementChild as Node, + manual: false, + immediate: true, + }) { + mutated(): void {} + } + + const el = document.createElement('article'); + const target = document.createElement('figure'); + el.append(target); + document.body.append(el); + const instance = new Watcher(el).$mount(); + + expect(observersOf(el)).toEqual([]); + expect(initOf(target)).toEqual( + resolvedInit({ childList: true, characterData: true, subtree: true }), + ); + expect(initOf(target)).not.toHaveProperty('target'); + expect(initOf(target)).not.toHaveProperty('manual'); + expect(initOf(target)).not.toHaveProperty('immediate'); + instance.$terminate(); + }); + + it('releases each automatic mount cycle and observes again on remount', () => { + const seen: number[] = []; + + class Watcher extends withMutation(Base) { + mutated({ records }: MutationProps): void { + seen.push(records.length); + } + } + + const el = document.createElement('article'); + document.body.append(el); + const instance = new Watcher(el).$mount(); + const first = observerOf(el); + first.deliver([recordFor(el)]); + expect(seen).toHaveLength(1); + + instance.$destroy(); + expect(first.disconnects).toBe(1); + + instance.$mount(); + const second = observersOf(el)[1]; + expect(second?.observed[0]?.target).toBe(el); + + second?.deliver([recordFor(el)]); + expect(seen).toHaveLength(2); + + instance.$terminate(); + expect(second?.disconnects).toBe(1); + }); + + it('leaves a manual hook stopped on mount and releases starts on destroy', () => { + const seen: number[] = []; + + class Watcher extends withMutation(Base, { manual: true }) { + mutated({ records }: MutationProps): void { + seen.push(records.length); + } + } + + const el = document.createElement('article'); + document.body.append(el); + const instance = new Watcher(el).$mount(); + + expect(observersOf(el)).toEqual([]); + expect(instance.$services.mutated.isActive).toBe(false); + + instance.$services.mutated.start(); + const observer = observerOf(el); + expect(instance.$services.mutated.isActive).toBe(true); + observer.deliver([recordFor(el)]); + expect(seen).toHaveLength(1); + + instance.$destroy(); + expect(instance.$services.mutated.isActive).toBe(false); + expect(observer.disconnects).toBe(1); + instance.$terminate(); + }); +}); diff --git a/packages/v4/src/services/mutation.ts b/packages/v4/src/services/mutation.ts index dc8e687aa..ec6ebb218 100644 --- a/packages/v4/src/services/mutation.ts +++ b/packages/v4/src/services/mutation.ts @@ -1,4 +1,5 @@ import { getSharedRuntimeSlot } from '../shared-runtime.js'; +import { createServiceMixin, type ServiceHandles, type ServiceMixinOptions } from './mixin.js'; import { createService, perTarget, type MutableProps, type Service } from './service.js'; export interface MutationProps { @@ -109,3 +110,45 @@ export function useMutation( ): Service { return mutationServices(target, resolveInit(init)); } + +/** The method `withMutation()` subscribes for the component. */ +export interface MutationHook { + mutated?(props: MutationProps): void; +} + +export type MutationMixinOptions = MutationObserverInit & ServiceMixinOptions; + +/** + * Subscribe `mutated()` for each mount cycle, watching the root element's + * subtree unless the options name another observation. + */ +export const withMutation = /* @__PURE__ */ createServiceMixin< + MutationHook & ServiceHandles<'mutated'>, + Node, + MutationObserverInit +>({ + hook: 'mutated', + target: (instance) => instance.$el, + use: (target, options) => { + const { + attributeFilter, + attributeOldValue, + attributes, + characterData, + characterDataOldValue, + childList, + subtree, + } = options; + const init: MutationObserverInit = { + ...(attributeFilter !== undefined && { attributeFilter }), + ...(attributeOldValue !== undefined && { attributeOldValue }), + ...(attributes !== undefined && { attributes }), + ...(characterData !== undefined && { characterData }), + ...(characterDataOldValue !== undefined && { characterDataOldValue }), + ...(childList !== undefined && { childList }), + ...(subtree !== undefined && { subtree }), + }; + // An options object holding only mixin keys names no observation. + return useMutation(target, Object.keys(init).length > 0 ? init : DEFAULT_INIT); + }, +}); diff --git a/packages/v4/src/subpaths/withMutation.ts b/packages/v4/src/subpaths/withMutation.ts new file mode 100644 index 000000000..dcaea3efb --- /dev/null +++ b/packages/v4/src/subpaths/withMutation.ts @@ -0,0 +1 @@ +export { withMutation, withMutation as default } from '../services/mutation.js'; diff --git a/packages/v4/test/package-node-consumer.js b/packages/v4/test/package-node-consumer.js index 72b70f7c6..09af867c2 100644 --- a/packages/v4/test/package-node-consumer.js +++ b/packages/v4/test/package-node-consumer.js @@ -10,6 +10,7 @@ import subscribeContextDefault, { } from '@studiometa/js-toolkit-v4/subscribeContext'; import useMutationDefault, { useMutation } from '@studiometa/js-toolkit-v4/useMutation'; import useRafDefault, { useRaf } from '@studiometa/js-toolkit-v4/useRaf'; +import withMutationDefault, { withMutation } from '@studiometa/js-toolkit-v4/withMutation'; import watchAttributesDefault, { watchAttributes } from '@studiometa/js-toolkit-v4/watchAttributes'; import createStorageDefault, { createStorage } from '@studiometa/js-toolkit-v4/createStorage'; import createMemoryStorageProviderDefault, { @@ -43,6 +44,8 @@ assert.equal(useRaf, toolkit.useRaf); assert.equal(useRafDefault, useRaf); assert.equal(useMutation, toolkit.useMutation); assert.equal(useMutationDefault, useMutation); +assert.equal(withMutation, toolkit.withMutation); +assert.equal(withMutationDefault, withMutation); assert.equal(watchAttributes, toolkit.watchAttributes); assert.equal(watchAttributesDefault, watchAttributes); assert.equal('$watchAttributes' in Base.prototype, false); @@ -50,7 +53,7 @@ assert.equal(createStorage, toolkit.createStorage); assert.equal(createStorageDefault, createStorage); assert.equal(createMemoryStorageProvider, toolkit.createMemoryStorageProvider); assert.equal(createMemoryStorageProviderDefault, createMemoryStorageProvider); -assert.equal(Object.keys(toolkit).length, 80); +assert.equal(Object.keys(toolkit).length, 81); assert.equal(toolkit.ToolkitErrorDetail, undefined); assert.equal(toolkit.ToolkitErrorStage, undefined); From ee02c3e59b916072fa4bdc50dae5bee69cf9f6bc Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Sun, 16 Aug 2026 10:28:20 +0000 Subject: [PATCH 3/3] docs(v4): record the mutation service in the design 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) Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9 --- packages/v4/DESIGN.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/v4/DESIGN.md b/packages/v4/DESIGN.md index 941bcddac..f784e07f0 100644 --- a/packages/v4/DESIGN.md +++ b/packages/v4/DESIGN.md @@ -330,6 +330,7 @@ So the opt-in is the standalone, element-scoped `watchAttributes(element, callba - **The records join the shared queue.** The element observer is drained wherever the engine drains its own — `whenDOMSettled()` included — and its changes are reported from the same background task, as step 5 above. So `swap()` covers a watched attribute exactly as it covers a mount, and there is no second timeline to reason about. - **After the framework, deliberately.** A callback must see settled component lifecycle and declared options rather than a half-reconciled batch. A component which stops its watcher from its mounted cleanup during same-batch termination therefore hears nothing about the accompanying attribute change. Nothing in the framework reads these arbitrary attributes, so no framework decision can depend on a callback, and the reverse order would have no reader. - **Coalesced like options.** Several writes to one attribute in a batch are one change, from the first old value to the final DOM value, and a rewrite ending where it started is not a change at all — the rule `$optionChanged()` already follows. +- **Not the general-purpose observer.** Watching a subtree, character data, or a node the framework knows nothing about is `useMutation()` in section 8, which owns its own observer and reports raw records on the platform's own timing. This helper is the one to reach for whenever the question is what an attribute became. - Delivered as one payload object: `{ name, value, previousValue }`, raw attribute strings, `null` on either side for an absent attribute. It is the **entire** attribute set of the element, framework names included; a caller narrows by prefix, which is what a `data-on:` or `data-bind:` family wants anyway. Callback failures are isolated through `EVENTS.diagnostic` with code `DIAGNOSTICS.callback.attributeWatcherFailed`, so one watcher cannot stop another. Measured from the preceding `origin/main` with esbuild tree shaking and minification, `morphdom` external and gzip level 9: @@ -722,7 +723,7 @@ Nothing in v4 needs it today, so nothing implements it: this project does not ad ## 8. Services — lazy, reference-counted — implemented -A service is a shared source of props components subscribe to: `ticked`, `scrolled`, `resized`, `moved`, `dragged`, `intersected`. `KeyService` and `LoadService` are not ported — a `keydown` listener and `window.onload` need no service around them. +A service is a shared source of props components subscribe to: `ticked`, `scrolled`, `resized`, `moved`, `dragged`, `intersected`, `mutated`. `KeyService` and `LoadService` are not ported — a `keydown` listener and `window.onload` need no service around them. > **Hardened after an adversarial review** (`SERVICES-REVIEW.md`, 2026-08-12): 17 confirmed defects, each with a regression test, plus the API changes recorded below. Three claims this section used to make were falsified by that review and are corrected in place. > @@ -734,9 +735,9 @@ 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, 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. `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. - **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` override `mounted()`, subscribe the component's `ticked`/`scrolled`/`resized`/`scrolledInView`/`moved`/`dragged`/`intersected` 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. +- **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: ```js @@ -745,6 +746,10 @@ A service is a shared source of props components subscribe to: `ticked`, `scroll } ``` +- **Mutations are a service too — `useMutation()`.** Section 3 keeps one filtered `MutationObserver` for the framework's own names, and opens that engine to one element's attributes through `watchAttributes()`. Neither covers "tell me when anything under this node changes", which is why `@studiometa/ui`'s `Disclosure` was writing an observer by hand. `useMutation(node, init?)` is that observer as a service, and it is the last resort of the three: `watchAttributes()` first for an attribute, since it coalesces and reports after component lifecycle has settled, and the registry's own observer for everything the framework already reconciles. This one delivers on the platform's own timing, and a subscriber that needs the framework's ordering awaits `whenDOMSettled()` from its callback. + + Its props are `{ records }` and it **keeps nothing after the delivery**. A `childList` record holds the nodes it removed, so a service retaining the last batch — as v3's persistent props object did — keeps a detached subtree alive for the life of the page. That also makes `hasProps()` honest, for the reason the frame tick has none between two frames: a batch is a mutation that happened, not a state that holds, so `props()` is empty between deliveries and `{ immediate: true }` waits for a real one. Its key is a **canonical** init rather than the raw options: property order, an unsorted or repeated `attributeFilter`, and the platform's own `attributeOldValue`/`characterDataOldValue` inferences all describe one observation and must not buy a second observer. The default observation is `{ childList: true, subtree: true }`, because attributes of one element are `watchAttributes()`'s job. + - **Suspendable within a cycle — `toggle()`.** A mount cycle is the right span for most subscriptions and the wrong one for a component that needs the frame loop only while something settles. `toggle(subscribe)` returns `{ isActive, start, stop }` over anything that hands back its own unsubscribe, with `start` and `stop` bound: ```js