From 3659531dea51adf465829aaaac596e1361aaffd2 Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Tue, 27 Apr 2021 08:48:20 -0400 Subject: [PATCH] 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/stimulus#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/stimulus#367]: https://github.com/hotwired/stimulus/pull/367 [event listen options]: https://stimulus.hotwire.dev/reference/actions#options [MutationObserverInit options]: https://developer.mozilla.org/en-US/docs/Web/API/MutationObserverInit#properties --- docs/reference/mutations.md | 136 ++++++++++++++++++++ src/tests/cases/dom_test_case.ts | 14 ++ src/tests/cases/log_controller_test_case.ts | 24 +++- src/tests/controllers/log_controller.ts | 32 +++++ src/tests/modules/core/mutation_tests.ts | 95 ++++++++++++++ 5 files changed, 299 insertions(+), 2 deletions(-) create mode 100644 docs/reference/mutations.md create mode 100644 src/tests/modules/core/mutation_tests.ts diff --git a/docs/reference/mutations.md b/docs/reference/mutations.md new file mode 100644 index 00000000..f53dfde7 --- /dev/null +++ b/docs/reference/mutations.md @@ -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. + + + +```html +
+ +
+``` + + + +```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: + + + +```html +
+``` + +### 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). + + + + +```html +
+``` + +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: + + + + +```html + +``` + +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`: + + + +```html + +``` + +Instead, name your action methods based on what will happen when they're called: + + + +```html +Do +``` + +This will help you reason about the behavior of a block of HTML without having to look at the controller source. diff --git a/src/tests/cases/dom_test_case.ts b/src/tests/cases/dom_test_case.ts index f132e6f4..e7bc25b9 100644 --- a/src/tests/cases/dom_test_case.ts +++ b/src/tests/cases/dom_test_case.ts @@ -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 diff --git a/src/tests/cases/log_controller_test_case.ts b/src/tests/cases/log_controller_test_case.ts index d1a44ec6..2112f571 100644 --- a/src/tests/cases/log_controller_test_case.ts +++ b/src/tests/cases/log_controller_test_case.ts @@ -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() } @@ -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 { diff --git a/src/tests/controllers/log_controller.ts b/src/tests/controllers/log_controller.ts index 397d7112..e1da8051 100644 --- a/src/tests/controllers/log_controller.ts +++ b/src/tests/controllers/log_controller.ts @@ -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 @@ -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) { @@ -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 + }) + } } diff --git a/src/tests/modules/core/mutation_tests.ts b/src/tests/modules/core/mutation_tests.ts new file mode 100644 index 00000000..196a84c1 --- /dev/null +++ b/src/tests/modules/core/mutation_tests.ts @@ -0,0 +1,95 @@ +import { LogControllerTestCase } from "../cases/log_controller_test_case" + +export default class MutationTests extends LogControllerTestCase { + identifier = "c" + fixtureHTML = ` +
+ +

Log

+
+
+
+
+
With Options Child
+
+
+
+
+ + + + ` + + 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", "
Ignored
") + 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" } + ) + } +}