Skip to content

Commit d24cb83

Browse files
committed
Proposal: Mutation Observation Syntax
Add attribute-level support for monitoring changes to attributes on the marked element _and_ its descendants. The `data-mutation` syntax draws direct inspiration from the `[data-action]` syntax for routing browser events. Similar to how the Action syntax supports [event listener options][], the Mutation syntax would support [MutationObserverInit options][] like `!subtree`. The proposed hooks only cover _attribute_ mutations, since the proposal made by [hotwired#367][] should cover `childList` type mutations like the addition or removal of controller targets. One alternative could involve combining `[data-mutation]` and `[data-action]` into a single DOMTokenList, and using additional symbols like `@...` or wrapping `[...]` as a differentiators (e.g. `@aria-expanded->disclosure#toggle` or `[aria-expanded]->disclosure#toggle`). Another could push this responsibility application-side by introducing more publicly available `MutationObserver` utilities like those used for `DOMTokenList` parsing or deconstructing the `[data-action]` directives. Once available, those utilities could be used by consumers to listen for their own mutations and "route" them to actions by combining action directive parsing and `Application.getControllerForElementAndIdentifier(element, identifier)` to invoke fuctions on a `Controller` instance. [hotwired#367]: hotwired#367 [event listen options]: https://stimulus.hotwire.dev/reference/actions#options [MutationObserverInit options]: https://developer.mozilla.org/en-US/docs/Web/API/MutationObserverInit#properties
1 parent c654078 commit d24cb83

File tree

5 files changed

+299
-2
lines changed

5 files changed

+299
-2
lines changed

docs/reference/mutations.md

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
---
2+
permalink: /reference/mutations
3+
order: 06
4+
---
5+
6+
# Mutations
7+
8+
_Mutations_ are how you handle changes to DOM elements and their attributes in your controllers.
9+
10+
<meta data-controller="callout" data-callout-text-value="aria-expanded->combobox#toggle">
11+
12+
```html
13+
<div data-controller="combobox">
14+
<input type="search" data-mutation="aria-expanded->combobox#toggle">
15+
</div>
16+
```
17+
18+
<meta data-controller="callout" data-callout-text-value="toggle">
19+
20+
```js
21+
// controllers/combobox_controller.js
22+
import { Controller } from "@hotwired/stimulus"
23+
24+
export default class extends Controller {
25+
toggle(mutationRecords) {
26+
//
27+
}
28+
}
29+
```
30+
31+
A mutation is a connection between:
32+
33+
* a controller method
34+
* the controller's element
35+
* a DOM mutation observer
36+
37+
## Descriptors
38+
39+
The `data-mutation` value `aria-expanded->combobox#toggle` is called a _mutation descriptor_. In this descriptor:
40+
41+
* `aria-expanded` is the name of the attribute to listen for changes to
42+
* `combobox` is the controller identifier
43+
* `toggle` is the name of the method to invoke
44+
45+
### Mutations Shorthand
46+
47+
Stimulus lets you shorten the mutation descriptors when observing mutations to _any_ attribute, by omitting the attribute name:
48+
49+
<meta data-controller="callout" data-callout-text-value="combobox#toggle">
50+
51+
```html
52+
<div data-controller="combobox" data-mutation="combobox#toggle">…</div>
53+
```
54+
55+
### Options
56+
57+
You can append one or more _mutation options_ to a mutation descriptor if you
58+
need to specify [MutationObserverInit
59+
options](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserverInit).
60+
61+
<meta data-controller="callout" data-callout-text-value="aria-expanded">
62+
<meta data-controller="callout" data-callout-text-value=":subtree">
63+
64+
```html
65+
<div data-controller="combobox" data-mutation="aria-expanded->combobox#toggle:subtree">…</div>
66+
```
67+
68+
When provided, the attribute name serves as the `attributeFilter` option and
69+
defaults the `{ attribute: true }`.
70+
71+
Stimulus supports the following mutation options:
72+
73+
Mutation option | MutationObserver option
74+
------------------------- | -------------------------
75+
`:subtree` | `{ subtree: true }`
76+
`:childList` | `{ childList: true }`
77+
`:attributes` | `{ attributes: true }`
78+
`:attributeOldValue` | `{ attributeOldValue: true }`
79+
`:characterData` | `{ characterData: true }`
80+
`:characterDataOldValue ` | `{ characterData: true }`
81+
82+
83+
## MutationRecord Objects
84+
85+
A _mutation method_ is the method in a controller which serves as an mutation's event listener.
86+
87+
The first argument to a mutation method is an array of DOM
88+
[MutationRecord](https://developer.mozilla.org/en-US/docs/Web/API/MutationRecord)
89+
_objects_. You may want access to the event for a number of reasons, including:
90+
91+
* to find out which element was mutated
92+
* to read the attribute's previous value
93+
94+
The following basic properties are common to all events:
95+
96+
MutationRecord Property | Value
97+
----------------------- | -----
98+
mutationRecord.type | The name of the event (e.g. `"click"`)
99+
mutationRecord.target | The target that dispatched the event (i.e. the innermost element that was clicked)
100+
101+
## Multiple Mutations
102+
103+
The `data-mutation` attribute's value is a space-separated list of mutation descriptors.
104+
105+
It's common for any given element to have many actions. For example, the following dialog element calls a `modal` controller's `backdrop()` method and a `focus` controller's `trap()` method when the `open` attribute changes:
106+
107+
<meta data-controller="callout" data-callout-text-value="open->modal#backdrop">
108+
<meta data-controller="callout" data-callout-text-value="open->focus#trap">
109+
110+
```html
111+
<dialog data-action="open->modal#backdrop open->focus#trap">
112+
```
113+
114+
When an element has more than one action for the same mutation, Stimulus invokes the actions from left to right in the order that their descriptors appear.
115+
116+
## Naming Conventions
117+
118+
Always use camelCase to specify action names, since they map directly to methods on your controller.
119+
120+
Avoid action names that repeat the mutation's name, such as `open`, `onOpen`, or `handleOpen`:
121+
122+
<meta data-controller="callout" data-callout-text-value="#open" data-callout-type-value="avoid">
123+
124+
```html
125+
<dialog data-action="open->modal#open">Don't</dialog>
126+
```
127+
128+
Instead, name your action methods based on what will happen when they're called:
129+
130+
<meta data-controller="callout" data-callout-text-value="#backdrop" data-callout-type-value="prefer">
131+
132+
```html
133+
<dialog data-action="open->modal#backdrop">Do</dialog>
134+
```
135+
136+
This will help you reason about the behavior of a block of HTML without having to look at the controller source.

src/tests/cases/dom_test_case.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,20 @@ export class DOMTestCase extends TestCase {
3333
}
3434
}
3535

36+
async setAttribute(selector: string, attributeName: string, value: string) {
37+
const element = this.findElement(selector)
38+
element.setAttribute(attributeName, value)
39+
40+
await this.nextFrame
41+
}
42+
43+
async removeAttribute(selector: string, attributeName: string) {
44+
const element = this.findElement(selector)
45+
element.removeAttribute(attributeName)
46+
47+
await this.nextFrame
48+
}
49+
3650
async triggerEvent(selectorOrTarget: string | EventTarget, type: string, options: TriggerEventOptions = {}) {
3751
const { bubbles, setDefaultPrevented } = { ...defaultTriggerEventOptions, ...options }
3852
const eventTarget = typeof selectorOrTarget == "string" ? this.findElement(selectorOrTarget) : selectorOrTarget

src/tests/cases/log_controller_test_case.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { ControllerTestCase } from "./controller_test_case"
2-
import { LogController, ActionLogEntry } from "../controllers/log_controller"
2+
import { LogController, ActionLogEntry, MutationLogEntry } from "../controllers/log_controller"
33
import { ControllerConstructor } from "../../core/controller"
44

55
export class LogControllerTestCase extends ControllerTestCase(LogController) {
6-
controllerConstructor!: ControllerConstructor & { actionLog: ActionLogEntry[] }
6+
controllerConstructor!: ControllerConstructor & { actionLog: ActionLogEntry[], mutationLog: MutationLogEntry[] }
77

88
async setup() {
99
this.controllerConstructor.actionLog = []
10+
this.controllerConstructor.mutationLog = []
1011
await super.setup()
1112
}
1213

@@ -28,6 +29,25 @@ export class LogControllerTestCase extends ControllerTestCase(LogController) {
2829
get actionLog(): ActionLogEntry[] {
2930
return this.controllerConstructor.actionLog
3031
}
32+
33+
assertMutations(...mutations: any[]) {
34+
this.assert.equal(this.mutationLog.length, mutations.length)
35+
36+
mutations.forEach((expected, index) => {
37+
const keys = Object.keys(expected)
38+
const actual = slice(this.mutationLog[index] || {}, keys)
39+
const result = keys.every(key => expected[key] === actual[key])
40+
this.assert.pushResult({ result, actual, expected, message: "" })
41+
})
42+
}
43+
44+
assertNoMutations() {
45+
this.assert.equal(this.mutationLog.length, 0)
46+
}
47+
48+
get mutationLog(): MutationLogEntry[] {
49+
return this.controllerConstructor.mutationLog
50+
}
3151
}
3252

3353
function slice(object: any, keys: string[]): any {

src/tests/controllers/log_controller.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,17 @@ export type ActionLogEntry = {
1212
passive: boolean
1313
}
1414

15+
export type MutationLogEntry = {
16+
attributeName: string | null,
17+
controller: Controller
18+
name: string,
19+
oldValue: string | null,
20+
type: string,
21+
}
22+
1523
export class LogController extends Controller {
1624
static actionLog: ActionLogEntry[] = []
25+
static mutationLog: MutationLogEntry[] = []
1726
initializeCount = 0
1827
connectCount = 0
1928
disconnectCount = 0
@@ -42,6 +51,14 @@ export class LogController extends Controller {
4251
this.recordAction("log3", event)
4352
}
4453

54+
logMutation(mutation: MutationRecord) {
55+
this.recordMutation("logMutation", mutation)
56+
}
57+
58+
logMutation2(mutation: MutationRecord) {
59+
this.recordMutation("logMutation2", mutation)
60+
}
61+
4562
logPassive(event: ActionEvent) {
4663
event.preventDefault()
4764
if (event.defaultPrevented) {
@@ -72,4 +89,19 @@ export class LogController extends Controller {
7289
passive: passive || false
7390
})
7491
}
92+
93+
get mutationLog() {
94+
return (this.constructor as typeof LogController).mutationLog
95+
}
96+
97+
private recordMutation(name: string, mutation: MutationRecord) {
98+
const { attributeName, oldValue, type } = mutation
99+
this.mutationLog.push({
100+
attributeName,
101+
controller: this,
102+
name,
103+
oldValue,
104+
type
105+
})
106+
}
75107
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { LogControllerTestCase } from "../cases/log_controller_test_case"
2+
3+
export default class MutationTests extends LogControllerTestCase {
4+
identifier = "c"
5+
fixtureHTML = `
6+
<div data-controller="c" data-mutation="contenteditable->c#logMutation">
7+
<button data-mutation="c#logMutation"><span>Log</span></button>
8+
<section data-mutation="id->c#logMutation"><p>Log</p></section>
9+
<div id="outer" data-mutation="contenteditable->c#logMutation">
10+
<div id="inner" data-controller="c" data-mutation="contenteditable->c#logMutation class->c#logMutation"></div>
11+
</div>
12+
<div id="with-options" data-controller="c" data-mutation="contenteditable->c#logMutation:!subtree">
13+
<div>With Options Child</div>
14+
</div>
15+
<div id="multiple" data-mutation="class->c#logMutation class->c#logMutation2"></div>
16+
</div>
17+
<div id="outside"></div>
18+
<svg id="svgRoot" data-controller="c" data-mutation="fill->c#logMutation">
19+
<circle id="svgChild" data-mutation="stroke->c#logMutation" cx="5" cy="5" r="5">
20+
</svg>
21+
`
22+
23+
async "test default mutation"() {
24+
await this.setAttribute("button", "id", "button")
25+
this.assertMutations({ type: "attribute", attributeName: "id", oldValue: null })
26+
27+
await this.removeAttribute("button", "id")
28+
this.assertMutations({ type: "attribute", attributeName: "id", oldValue: "button" })
29+
}
30+
31+
async "test bubbling mutations"() {
32+
await this.setAttribute("span", "id", "span")
33+
this.assertMutations({ type: "attribute", attributeName: "id", oldValue: null })
34+
35+
await this.removeAttribute("span", "id")
36+
this.assertMutations({ type: "attribute", attributeName: "id", oldValue: "button" })
37+
}
38+
39+
async "test non-bubbling mutations"() {
40+
await this.setAttribute("section p", "role", "presentation")
41+
this.assertNoActions()
42+
43+
const section = await this.findElement("section")
44+
await section.insertAdjacentHTML("beforeend", "<div>Ignored</div>")
45+
this.assertNoActions()
46+
47+
const div = await this.findElement("section div")
48+
await section.removeChild(div)
49+
this.assertNoActions()
50+
}
51+
52+
async "test nested mutations"() {
53+
const innerController = this.controllers[1]
54+
55+
await this.setAttribute("#inner", "contenteditable", "")
56+
this.assertMutations({ controller: innerController, type: "attribute", attributeName: "contenteditable", oldValue: null })
57+
58+
await this.removeAttribute("#inner", "contenteditable")
59+
this.assertMutations({ controller: innerController, type: "attribute", attributeName: "contenteditable", oldValue: "" })
60+
61+
await this.setAttribute("#inner", "class", "mutated")
62+
this.assertMutations({ controller: innerController, type: "attribute", attributeName: "class", oldValue: null })
63+
}
64+
65+
async "test with options"() {
66+
await this.setAttribute("#with-options div", "contenteditable", "")
67+
this.assertNoMutations()
68+
69+
await this.setAttribute("#with-options", "contenteditable", "")
70+
this.assertMutations({ type: "attribute", attributeName: "class", oldValue: null })
71+
}
72+
73+
async "test multiple mutations"() {
74+
await this.setAttribute("#multiple", "class", "mutated")
75+
this.assertMutations(
76+
{ name: "logMutation", attributeName: "class", oldValue: null },
77+
{ name: "logMutation2", attributeName: "class", oldValue: null },
78+
)
79+
80+
await this.removeAttribute("#multiple", "class")
81+
this.assertMutations(
82+
{ name: "logMutation", attributeName: "class", oldValue: "mutated" },
83+
{ name: "logMutation2", attributeName: "class", oldValue: "mutated" },
84+
)
85+
}
86+
87+
async "test mutations on svg elements"() {
88+
await this.setAttribute("#svgRoot", "fill", "#fff")
89+
await this.setAttribute("#svgChild", "stroke", "#000")
90+
this.assertActions(
91+
{ name: "mutationLog", attributeName: "fill" },
92+
{ name: "mutationLog", attributeName: "stroke" }
93+
)
94+
}
95+
}

0 commit comments

Comments
 (0)