Skip to content

Commit

Permalink
Proposal: Mutation Observation Syntax
Browse files Browse the repository at this point in the history
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
  • Loading branch information
seanpdoyle committed Sep 12, 2021
1 parent c654078 commit 3659531
Show file tree
Hide file tree
Showing 5 changed files with 299 additions and 2 deletions.
136 changes: 136 additions & 0 deletions docs/reference/mutations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
---
permalink: /reference/mutations
order: 06
---

# Mutations

_Mutations_ are how you handle changes to DOM elements and their attributes in your controllers.

<meta data-controller="callout" data-callout-text-value="aria-expanded->combobox#toggle">

```html
<div data-controller="combobox">
<input type="search" data-mutation="aria-expanded->combobox#toggle">
</div>
```

<meta data-controller="callout" data-callout-text-value="toggle">

```js
// controllers/combobox_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
toggle(mutationRecords) {
//
}
}
```

A mutation is a connection between:

* a controller method
* the controller's element
* a DOM mutation observer

## Descriptors

The `data-mutation` value `aria-expanded->combobox#toggle` is called a _mutation descriptor_. In this descriptor:

* `aria-expanded` is the name of the attribute to listen for changes to
* `combobox` is the controller identifier
* `toggle` is the name of the method to invoke

### Mutations Shorthand

Stimulus lets you shorten the mutation descriptors when observing mutations to _any_ attribute, by omitting the attribute name:

<meta data-controller="callout" data-callout-text-value="combobox#toggle">

```html
<div data-controller="combobox" data-mutation="combobox#toggle">…</div>
```

### Options

You can append one or more _mutation options_ to a mutation descriptor if you
need to specify [MutationObserverInit
options](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserverInit).

<meta data-controller="callout" data-callout-text-value="aria-expanded">
<meta data-controller="callout" data-callout-text-value=":subtree">

```html
<div data-controller="combobox" data-mutation="aria-expanded->combobox#toggle:subtree">…</div>
```

When provided, the attribute name serves as the `attributeFilter` option and
defaults the `{ attribute: true }`.

Stimulus supports the following mutation options:

Mutation option | MutationObserver option
------------------------- | -------------------------
`:subtree` | `{ subtree: true }`
`:childList` | `{ childList: true }`
`:attributes` | `{ attributes: true }`
`:attributeOldValue` | `{ attributeOldValue: true }`
`:characterData` | `{ characterData: true }`
`:characterDataOldValue ` | `{ characterData: true }`


## MutationRecord Objects

A _mutation method_ is the method in a controller which serves as an mutation's event listener.

The first argument to a mutation method is an array of DOM
[MutationRecord](https://developer.mozilla.org/en-US/docs/Web/API/MutationRecord)
_objects_. You may want access to the event for a number of reasons, including:

* to find out which element was mutated
* to read the attribute's previous value

The following basic properties are common to all events:

MutationRecord Property | Value
----------------------- | -----
mutationRecord.type | The name of the event (e.g. `"click"`)
mutationRecord.target | The target that dispatched the event (i.e. the innermost element that was clicked)

## Multiple Mutations

The `data-mutation` attribute's value is a space-separated list of mutation descriptors.

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:

<meta data-controller="callout" data-callout-text-value="open->modal#backdrop">
<meta data-controller="callout" data-callout-text-value="open->focus#trap">

```html
<dialog data-action="open->modal#backdrop open->focus#trap">
```

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.

## Naming Conventions

Always use camelCase to specify action names, since they map directly to methods on your controller.

Avoid action names that repeat the mutation's name, such as `open`, `onOpen`, or `handleOpen`:

<meta data-controller="callout" data-callout-text-value="#open" data-callout-type-value="avoid">

```html
<button data-action="open->modal#open">Don't</dialog>
```

Instead, name your action methods based on what will happen when they're called:

<meta data-controller="callout" data-callout-text-value="#backdrop" data-callout-type-value="prefer">

```html
<dialog data-action="open->modal#backdrop">Do</dialog>
```

This will help you reason about the behavior of a block of HTML without having to look at the controller source.
14 changes: 14 additions & 0 deletions src/tests/cases/dom_test_case.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@ export class DOMTestCase extends TestCase {
}
}

async setAttribute(selector: string, attributeName: string, value: string) {
const element = this.findElement(selector)
element.setAttribute(attributeName, value)

await this.nextFrame
}

async removeAttribute(selector: string, attributeName: string) {
const element = this.findElement(selector)
element.removeAttribute(attributeName)

await this.nextFrame
}

async triggerEvent(selectorOrTarget: string | EventTarget, type: string, options: TriggerEventOptions = {}) {
const { bubbles, setDefaultPrevented } = { ...defaultTriggerEventOptions, ...options }
const eventTarget = typeof selectorOrTarget == "string" ? this.findElement(selectorOrTarget) : selectorOrTarget
Expand Down
24 changes: 22 additions & 2 deletions src/tests/cases/log_controller_test_case.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { ControllerTestCase } from "./controller_test_case"
import { LogController, ActionLogEntry } from "../controllers/log_controller"
import { LogController, ActionLogEntry, MutationLogEntry } from "../controllers/log_controller"
import { ControllerConstructor } from "../../core/controller"

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

async setup() {
this.controllerConstructor.actionLog = []
this.controllerConstructor.mutationLog = []
await super.setup()
}

Expand All @@ -28,6 +29,25 @@ export class LogControllerTestCase extends ControllerTestCase(LogController) {
get actionLog(): ActionLogEntry[] {
return this.controllerConstructor.actionLog
}

assertMutations(...mutations: any[]) {
this.assert.equal(this.mutationLog.length, mutations.length)

mutations.forEach((expected, index) => {
const keys = Object.keys(expected)
const actual = slice(this.mutationLog[index] || {}, keys)
const result = keys.every(key => expected[key] === actual[key])
this.assert.pushResult({ result, actual, expected, message: "" })
})
}

assertNoMutations() {
this.assert.equal(this.mutationLog.length, 0)
}

get mutationLog(): MutationLogEntry[] {
return this.controllerConstructor.mutationLog
}
}

function slice(object: any, keys: string[]): any {
Expand Down
32 changes: 32 additions & 0 deletions src/tests/controllers/log_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,17 @@ export type ActionLogEntry = {
passive: boolean
}

export type MutationLogEntry = {
attributeName: string | null,
controller: Controller
name: string,
oldValue: string | null,
type: string,
}

export class LogController extends Controller {
static actionLog: ActionLogEntry[] = []
static mutationLog: MutationLogEntry[] = []
initializeCount = 0
connectCount = 0
disconnectCount = 0
Expand Down Expand Up @@ -42,6 +51,14 @@ export class LogController extends Controller {
this.recordAction("log3", event)
}

logMutation(mutation: MutationRecord) {
this.recordMutation("logMutation", mutation)
}

logMutation2(mutation: MutationRecord) {
this.recordMutation("logMutation2", mutation)
}

logPassive(event: ActionEvent) {
event.preventDefault()
if (event.defaultPrevented) {
Expand Down Expand Up @@ -72,4 +89,19 @@ export class LogController extends Controller {
passive: passive || false
})
}

get mutationLog() {
return (this.constructor as typeof LogController).mutationLog
}

private recordMutation(name: string, mutation: MutationRecord) {
const { attributeName, oldValue, type } = mutation
this.mutationLog.push({
attributeName,
controller: this,
name,
oldValue,
type
})
}
}
95 changes: 95 additions & 0 deletions src/tests/modules/core/mutation_tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { LogControllerTestCase } from "../cases/log_controller_test_case"

export default class MutationTests extends LogControllerTestCase {
identifier = "c"
fixtureHTML = `
<div data-controller="c" data-mutation="contenteditable->c#logMutation">
<button data-mutation="c#logMutation"><span>Log</span></button>
<section data-mutation="id->c#logMutation"><p>Log</p></section>
<div id="outer" data-mutation="contenteditable->c#logMutation">
<div id="inner" data-controller="c" data-mutation="contenteditable->c#logMutation class->c#logMutation"></div>
</div>
<div id="with-options" data-controller="c" data-mutation="contenteditable->c#logMutation:!subtree">
<div>With Options Child</div>
</div>
<div id="multiple" data-mutation="class->c#logMutation class->c#logMutation2"></div>
</div>
<div id="outside"></div>
<svg id="svgRoot" data-controller="c" data-mutation="fill->c#logMutation">
<circle id="svgChild" data-mutation="stroke->c#logMutation" cx="5" cy="5" r="5">
</svg>
`

async "test default mutation"() {
await this.setAttribute("button", "id", "button")
this.assertMutations({ type: "attribute", attributeName: "id", oldValue: null })

await this.removeAttribute("button", "id")
this.assertMutations({ type: "attribute", attributeName: "id", oldValue: "button" })
}

async "test bubbling mutations"() {
await this.setAttribute("span", "id", "span")
this.assertMutations({ type: "attribute", attributeName: "id", oldValue: null })

await this.removeAttribute("span", "id")
this.assertMutations({ type: "attribute", attributeName: "id", oldValue: "button" })
}

async "test non-bubbling mutations"() {
await this.setAttribute("section p", "role", "presentation")
this.assertNoActions()

const section = await this.findElement("section")
await section.insertAdjacentHTML("beforeend", "<div>Ignored</div>")
this.assertNoActions()

const div = await this.findElement("section div")
await section.removeChild(div)
this.assertNoActions()
}

async "test nested mutations"() {
const innerController = this.controllers[1]

await this.setAttribute("#inner", "contenteditable", "")
this.assertMutations({ controller: innerController, type: "attribute", attributeName: "contenteditable", oldValue: null })

await this.removeAttribute("#inner", "contenteditable")
this.assertMutations({ controller: innerController, type: "attribute", attributeName: "contenteditable", oldValue: "" })

await this.setAttribute("#inner", "class", "mutated")
this.assertMutations({ controller: innerController, type: "attribute", attributeName: "class", oldValue: null })
}

async "test with options"() {
await this.setAttribute("#with-options div", "contenteditable", "")
this.assertNoMutations()

await this.setAttribute("#with-options", "contenteditable", "")
this.assertMutations({ type: "attribute", attributeName: "class", oldValue: null })
}

async "test multiple mutations"() {
await this.setAttribute("#multiple", "class", "mutated")
this.assertMutations(
{ name: "logMutation", attributeName: "class", oldValue: null },
{ name: "logMutation2", attributeName: "class", oldValue: null },
)

await this.removeAttribute("#multiple", "class")
this.assertMutations(
{ name: "logMutation", attributeName: "class", oldValue: "mutated" },
{ name: "logMutation2", attributeName: "class", oldValue: "mutated" },
)
}

async "test mutations on svg elements"() {
await this.setAttribute("#svgRoot", "fill", "#fff")
await this.setAttribute("#svgChild", "stroke", "#000")
this.assertActions(
{ name: "mutationLog", attributeName: "fill" },
{ name: "mutationLog", attributeName: "stroke" }
)
}
}

0 comments on commit 3659531

Please sign in to comment.