From 12df44b7a3aaf2c8c3faf371ac416c8ecc3ab318 Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Tue, 28 Sep 2021 12:12:16 -0700 Subject: [PATCH] Add element and target attribute change callbacks Closes https://github.com/hotwired/stimulus/issues/445 --- Implement [attributeChange][] callbacks for both a controller's element and its targets. The `attributeChanged` and `[name]TargetAttributeChanged` callback signatures are styled after the Custom Element's `attributeChangedCallback`. The target variations accept the target element as the first argument, but are otherwise the same. It feels more appropriate to strive for parity in the interface than it is to yield the underlying [MutationRecord][] to call sites. [attributeChange]: https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#using_the_lifecycle_callbacks [MutationRecord]: https://developer.mozilla.org/en-US/docs/Web/API/MutationRecord --- docs/reference/lifecycle_callbacks.md | 2 + src/core/context.ts | 28 ++++++++++++- src/core/target_observer.ts | 38 +++++++++++++++++- src/mutation-observers/element_observer.ts | 28 ++++++++++--- src/tests/controllers/log_controller.ts | 25 ++++++++++++ src/tests/controllers/target_controller.ts | 21 +++++++--- src/tests/modules/core/lifecycle_tests.ts | 32 +++++++++++++++ src/tests/modules/core/target_tests.ts | 46 +++++++++++++++++----- 8 files changed, 196 insertions(+), 24 deletions(-) diff --git a/docs/reference/lifecycle_callbacks.md b/docs/reference/lifecycle_callbacks.md index dc5bb55e..ab1eb90e 100644 --- a/docs/reference/lifecycle_callbacks.md +++ b/docs/reference/lifecycle_callbacks.md @@ -28,6 +28,8 @@ Method | Invoked by Stimulus… initialize() | Once, when the controller is first instantiated connect() | Anytime the controller is connected to the DOM [name]TargetConnected(target: Element) | Anytime a target is connected to the DOM +attributeChanged(attributeName: string, oldValue: string | null, newValue: string | null) | Anytime the controller element's attributes change +[name]TargetAttributeChanged(target: Element, attributeName: string, oldValue: string | null, newValue: string | null) | Anytime a target element's attributes change disconnect() | Anytime the controller is disconnected from the DOM [name]TargetDisconnected(target: Element) | Anytime a target is disconnected from the DOM diff --git a/src/core/context.ts b/src/core/context.ts index 501aaf69..659c096b 100644 --- a/src/core/context.ts +++ b/src/core/context.ts @@ -8,14 +8,16 @@ import { Schema } from "./schema" import { Scope } from "./scope" import { ValueObserver } from "./value_observer" import { TargetObserver, TargetObserverDelegate } from "./target_observer" +import { ElementObserver, ElementObserverDelegate } from "../mutation-observers/element_observer" -export class Context implements ErrorHandler, TargetObserverDelegate { +export class Context implements ErrorHandler, TargetObserverDelegate, ElementObserverDelegate { readonly module: Module readonly scope: Scope readonly controller: Controller private bindingObserver: BindingObserver private valueObserver: ValueObserver private targetObserver: TargetObserver + private elementObserver: ElementObserver constructor(module: Module, scope: Scope) { this.module = module @@ -24,6 +26,7 @@ export class Context implements ErrorHandler, TargetObserverDelegate { this.bindingObserver = new BindingObserver(this, this.dispatcher) this.valueObserver = new ValueObserver(this, this.controller) this.targetObserver = new TargetObserver(this, this) + this.elementObserver = new ElementObserver(this.element, this, { subtree: false, childList: false, attributeOldValue: true }) try { this.controller.initialize() @@ -37,6 +40,7 @@ export class Context implements ErrorHandler, TargetObserverDelegate { this.bindingObserver.start() this.valueObserver.start() this.targetObserver.start() + this.elementObserver.start() try { this.controller.connect() @@ -54,6 +58,7 @@ export class Context implements ErrorHandler, TargetObserverDelegate { this.handleError(error, "disconnecting controller") } + this.elementObserver.stop() this.targetObserver.stop() this.valueObserver.stop() this.bindingObserver.stop() @@ -109,6 +114,27 @@ export class Context implements ErrorHandler, TargetObserverDelegate { this.invokeControllerMethod(`${name}TargetDisconnected`, element) } + targetAttributeChanged(element: Element, name: string, attributeName: string, oldValue: string | null, newValue: string | null) { + this.invokeControllerMethod(`${name}TargetAttributeChanged`, element, attributeName, oldValue, newValue) + } + + // Element observer delegate + + matchElement(element: Element) { + return element === this.element + } + + matchElementsInTree(tree: Element) { + return this.matchElement(tree) ? [tree] : [] + } + + elementAttributeChanged(element: Element, attributeName: string, mutation: MutationRecord) { + const oldValue = mutation.oldValue + const newValue = element.getAttribute(attributeName) + + this.elementObserver?.pause(() => this.invokeControllerMethod("attributeChanged", attributeName, oldValue, newValue)) + } + // Private invokeControllerMethod(methodName: string, ...args: any[]) { diff --git a/src/core/target_observer.ts b/src/core/target_observer.ts index 05230ab9..3f556269 100644 --- a/src/core/target_observer.ts +++ b/src/core/target_observer.ts @@ -1,17 +1,19 @@ import { Multimap } from "../multimap" -import { Token, TokenListObserver, TokenListObserverDelegate } from "../mutation-observers" +import { ElementObserver, ElementObserverDelegate, Token, TokenListObserver, TokenListObserverDelegate } from "../mutation-observers" import { Context } from "./context" export interface TargetObserverDelegate { targetConnected(element: Element, name: string): void targetDisconnected(element: Element, name: string): void + targetAttributeChanged(element: Element, name: string, attributeName: string, oldValue: string | null, newValue: string | null): void } -export class TargetObserver implements TokenListObserverDelegate { +export class TargetObserver implements ElementObserverDelegate, TokenListObserverDelegate { readonly context: Context readonly delegate: TargetObserverDelegate readonly targetsByName: Multimap private tokenListObserver?: TokenListObserver + private elementObserver?: ElementObserver constructor(context: Context, delegate: TargetObserverDelegate) { this.context = context @@ -24,6 +26,10 @@ export class TargetObserver implements TokenListObserverDelegate { this.tokenListObserver = new TokenListObserver(this.element, this.attributeName, this) this.tokenListObserver.start() } + if (!this.elementObserver) { + this.elementObserver = new ElementObserver(this.element, this, { attributeOldValue: true }) + this.elementObserver.start() + } } stop() { @@ -32,6 +38,10 @@ export class TargetObserver implements TokenListObserverDelegate { this.tokenListObserver.stop() delete this.tokenListObserver } + if (this.elementObserver) { + this.elementObserver.stop() + delete this.elementObserver + } } // Token list observer delegate @@ -46,6 +56,22 @@ export class TargetObserver implements TokenListObserverDelegate { this.disconnectTarget(element, name) } + // Element observer delegate + + matchElement(element: Element) { + return this.targetsByName.hasValue(element) + } + + matchElementsInTree(tree: Element) { + return this.targetsByName.values + } + + elementAttributeChanged(element: Element, attributeName: string, mutationRecord: MutationRecord) { + for (const name of this.targetsByName.getKeysForValue(element)) { + this.mutateTarget(element, name, attributeName, mutationRecord) + } + } + // Target management connectTarget(element: Element, name: string) { @@ -70,6 +96,14 @@ export class TargetObserver implements TokenListObserverDelegate { } } + mutateTarget(element: Element, name: string, attributeName: string, { oldValue }: MutationRecord) { + const newValue = element.getAttribute(attributeName) + + this.elementObserver?.pause(() => { + this.delegate.targetAttributeChanged(element, name, attributeName, oldValue, newValue) + }) + } + // Private private get attributeName() { diff --git a/src/mutation-observers/element_observer.ts b/src/mutation-observers/element_observer.ts index aa172c97..d1ff7780 100644 --- a/src/mutation-observers/element_observer.ts +++ b/src/mutation-observers/element_observer.ts @@ -4,7 +4,7 @@ export interface ElementObserverDelegate { elementMatched?(element: Element): void elementUnmatched?(element: Element): void - elementAttributeChanged?(element: Element, attributeName: string): void + elementAttributeChanged?(element: Element, attributeName: string, mutationRecord: MutationRecord): void } export class ElementObserver { @@ -14,24 +14,40 @@ export class ElementObserver { private elements: Set private mutationObserver: MutationObserver + private mutationObserverInit: MutationObserverInit - constructor(element: Element, delegate: ElementObserverDelegate) { + constructor(element: Element, delegate: ElementObserverDelegate, mutationObserverInit: Partial = {}) { this.element = element this.started = false this.delegate = delegate this.elements = new Set this.mutationObserver = new MutationObserver((mutations) => this.processMutations(mutations)) + this.mutationObserverInit = mutationObserverInit } start() { if (!this.started) { this.started = true - this.mutationObserver.observe(this.element, { attributes: true, childList: true, subtree: true }) + this.mutationObserver.observe(this.element, { attributes: true, childList: true, subtree: true, ...this.mutationObserverInit }) this.refresh() } } + pause(callback: () => void) { + if (this.started) { + this.mutationObserver.disconnect() + this.started = false + } + + callback() + + if (!this.started) { + this.mutationObserver.observe(this.element, { attributes: true, childList: true, subtree: true, ...this.mutationObserverInit }) + this.started = true + } + } + stop() { if (this.started) { this.mutationObserver.takeRecords() @@ -68,18 +84,18 @@ export class ElementObserver { private processMutation(mutation: MutationRecord) { if (mutation.type == "attributes") { - this.processAttributeChange(mutation.target, mutation.attributeName!) + this.processAttributeChange(mutation.target, mutation.attributeName!, mutation) } else if (mutation.type == "childList") { this.processRemovedNodes(mutation.removedNodes) this.processAddedNodes(mutation.addedNodes) } } - private processAttributeChange(node: Node, attributeName: string) { + private processAttributeChange(node: Node, attributeName: string, mutationRecord: MutationRecord) { const element = node as Element if (this.elements.has(element)) { if (this.delegate.elementAttributeChanged && this.matchElement(element)) { - this.delegate.elementAttributeChanged(element, attributeName) + this.delegate.elementAttributeChanged(element, attributeName, mutationRecord) } else { this.removeElement(element) } diff --git a/src/tests/controllers/log_controller.ts b/src/tests/controllers/log_controller.ts index 397d7112..aacefa24 100644 --- a/src/tests/controllers/log_controller.ts +++ b/src/tests/controllers/log_controller.ts @@ -12,11 +12,21 @@ export type ActionLogEntry = { passive: boolean } +export type MutationLogEntry = { + attributeName: string, + controller: Controller + identifier: string + newValue: string | null, + oldValue: string | null, +} + export class LogController extends Controller { static actionLog: ActionLogEntry[] = [] initializeCount = 0 connectCount = 0 disconnectCount = 0 + attributeChangedCount = 0 + mutationLog: MutationLogEntry[] = [] initialize() { this.initializeCount++ @@ -30,6 +40,10 @@ export class LogController extends Controller { this.disconnectCount++ } + attributeChanged(attributeName: string, oldValue: string | null, newValue: string | null) { + this.recordMutation(attributeName, oldValue, newValue) + } + log(event: ActionEvent) { this.recordAction("log", event) } @@ -72,4 +86,15 @@ export class LogController extends Controller { passive: passive || false }) } + + private recordMutation(attributeName: string, oldValue: string | null, newValue: string | null) { + this.attributeChangedCount++ + this.mutationLog.push({ + attributeName, + oldValue, + newValue, + controller: this, + identifier: this.identifier, + }) + } } diff --git a/src/tests/controllers/target_controller.ts b/src/tests/controllers/target_controller.ts index ff4835af..3a1c51c9 100644 --- a/src/tests/controllers/target_controller.ts +++ b/src/tests/controllers/target_controller.ts @@ -9,9 +9,9 @@ class BaseTargetController extends Controller { } export class TargetController extends BaseTargetController { - static classes = [ "connected", "disconnected" ] + static classes = [ "connected", "disconnected", "attributeChanged" ] static targets = [ "beta", "input" ] - static values = { inputTargetConnectedCallCount: Number, inputTargetDisconnectedCallCount: Number } + static values = { inputTargetConnectedCallCount: Number, inputTargetDisconnectedCallCount: Number, betaTargetAttributeChangedCallCountValue: Number } betaTarget!: Element | null betaTargets!: Element[] @@ -23,19 +23,28 @@ export class TargetController extends BaseTargetController { hasConnectedClass!: boolean hasDisconnectedClass!: boolean + hasAttributeChangedClass!: boolean connectedClass!: string disconnectedClass!: string + attributeChangedClass!: string inputTargetConnectedCallCountValue = 0 inputTargetDisconnectedCallCountValue = 0 + betaTargetAttributeChangedCallCountValue = 0 - inputTargetConnected(element: Element) { - if (this.hasConnectedClass) element.classList.add(this.connectedClass) + inputTargetConnected(target: Element) { + if (this.hasConnectedClass) target.classList.add(this.connectedClass) this.inputTargetConnectedCallCountValue++ } - inputTargetDisconnected(element: Element) { - if (this.hasDisconnectedClass) element.classList.add(this.disconnectedClass) + inputTargetDisconnected(target: Element) { + if (this.hasDisconnectedClass) target.classList.add(this.disconnectedClass) this.inputTargetDisconnectedCallCountValue++ } + + betaTargetAttributeChanged(target: Element, attributeName: string, ...args: any[]) { + if (this.hasAttributeChangedClass) target.classList.add(this.attributeChangedClass) + this.betaTargetAttributeChangedCallCountValue++ + target.setAttribute(attributeName, args.join(",")) + } } diff --git a/src/tests/modules/core/lifecycle_tests.ts b/src/tests/modules/core/lifecycle_tests.ts index 19d11543..b6b39223 100644 --- a/src/tests/modules/core/lifecycle_tests.ts +++ b/src/tests/modules/core/lifecycle_tests.ts @@ -28,6 +28,38 @@ export default class LifecycleTests extends LogControllerTestCase { this.assert.equal(controller.disconnectCount, 1) } + async "test Controller#attributeChanged called when element changes an attribute"() { + const controller = this.controller + + this.assert.equal(controller.attributeChangedCount, 0) + + await this.setElementAttribute(this.controller.element, "data-changed", "new-value") + + const lastMutation = controller.mutationLog.pop() + this.assert.equal(controller.attributeChangedCount, 1) + this.assert.equal(lastMutation?.attributeName, "data-changed") + this.assert.equal(lastMutation?.oldValue, null) + this.assert.equal(lastMutation?.newValue, "new-value") + } + + async "test Controller#attributeChanged not called when descendant changes an attribute"() { + const controller = this.controller + this.controllerElement.insertAdjacentHTML("beforeend", `
`) + const element = controller.element.querySelector("#child") + + this.assert.equal(controller.attributeChangedCount, 0) + + await this.setElementAttribute(element!, "data-changed", "new-value") + + this.assert.equal(controller.attributeChangedCount, 0) + this.assert.equal(controller.mutationLog.length, 0) + } + + async setElementAttribute(element: Element, attributeName: string, value: string) { + element.setAttribute(attributeName, value) + await this.nextFrame + } + async reconnectControllerElement() { await this.disconnectControllerElement() await this.connectControllerElement() diff --git a/src/tests/modules/core/target_tests.ts b/src/tests/modules/core/target_tests.ts index f0f3dc08..734ec635 100644 --- a/src/tests/modules/core/target_tests.ts +++ b/src/tests/modules/core/target_tests.ts @@ -3,7 +3,7 @@ import { TargetController } from "../../controllers/target_controller" export default class TargetTests extends ControllerTestCase(TargetController) { fixtureHTML = ` -
+
@@ -63,14 +63,14 @@ export default class TargetTests extends ControllerTestCase(TargetController) { this.assert.throws(() => this.controller.betaTarget) } - "test target connected callback fires after initialize() and when calling connect()"() { + "test [target]Connected callback fires after initialize() and when calling connect()"() { const connectedInputs = this.controller.inputTargets.filter(target => target.classList.contains("connected")) this.assert.equal(connectedInputs.length, 1) this.assert.equal(this.controller.inputTargetConnectedCallCountValue, 1) } - async "test target connected callback when element is inserted"() { + async "test [target]Connected callback when element is inserted"() { const connectedInput = document.createElement("input") connectedInput.setAttribute(`data-${this.controller.identifier}-target`, "input") @@ -84,7 +84,7 @@ export default class TargetTests extends ControllerTestCase(TargetController) { this.assert.ok(connectedInput.isConnected, "element is present in document") } - async "test target connected callback when present element adds the target attribute"() { + async "test [target]Connected callback when present element adds the target attribute"() { const element = this.findElement("#alpha1") this.assert.equal(this.controller.inputTargetConnectedCallCountValue, 1) @@ -97,7 +97,7 @@ export default class TargetTests extends ControllerTestCase(TargetController) { this.assert.ok(element.isConnected, "element is still present in document") } - async "test target connected callback when element adds a token to an existing target attribute"() { + async "test [target]Connected callback when element adds a token to an existing target attribute"() { const element = this.findElement("#alpha1") this.assert.equal(this.controller.inputTargetConnectedCallCountValue, 1) @@ -110,7 +110,7 @@ export default class TargetTests extends ControllerTestCase(TargetController) { this.assert.ok(element.isConnected, "element is still present in document") } - async "test target disconnected callback fires when calling disconnect() on the controller"() { + async "test [target]Disconnected callback fires when calling disconnect() on the controller"() { this.assert.equal(this.controller.inputTargets.filter(target => target.classList.contains("disconnected")).length, 0) this.assert.equal(this.controller.inputTargetDisconnectedCallCountValue, 0) @@ -121,7 +121,7 @@ export default class TargetTests extends ControllerTestCase(TargetController) { this.assert.equal(this.controller.inputTargetDisconnectedCallCountValue, 1) } - async "test target disconnected callback when element is removed"() { + async "test [target]Disconnected callback when element is removed"() { const disconnectedInput = this.findElement("#input1") this.assert.equal(this.controller.inputTargetDisconnectedCallCountValue, 0) @@ -135,7 +135,7 @@ export default class TargetTests extends ControllerTestCase(TargetController) { this.assert.notOk(disconnectedInput.isConnected, "element is not present in document") } - async "test target disconnected callback when an element present in the document removes the target attribute"() { + async "test [target]Disconnected callback when an element present in the document removes the target attribute"() { const element = this.findElement("#input1") this.assert.equal(this.controller.inputTargetDisconnectedCallCountValue, 0) @@ -149,7 +149,7 @@ export default class TargetTests extends ControllerTestCase(TargetController) { this.assert.ok(element.isConnected, "element is still present in document") } - async "test target disconnected(), then connected() callback fired when the target name is present after the attribute change"() { + async "test [target]Disconnected, then [target]Connected callback fired when the target name is present after the attribute change"() { const element = this.findElement("#input1") this.assert.equal(this.controller.inputTargetConnectedCallCountValue, 1) @@ -164,4 +164,32 @@ export default class TargetTests extends ControllerTestCase(TargetController) { this.assert.ok(element.classList.contains("disconnected"), `expected "${element.className}" to contain "disconnected"`) this.assert.ok(element.isConnected, "element is still present in document") } + + async "test calls [target]AttributeChanged callback when target changes an attribute"() { + const beta = this.findElement("#beta1") + + this.assert.equal(this.controller.betaTargetAttributeChangedCallCountValue, 0) + this.assert.notOk(beta.classList.contains("changed"), `expected "${beta.className}" not to contain "changed"`) + + beta.setAttribute("data-changed", "newValue") + await this.nextFrame + + this.assert.equal(beta.getAttribute("data-changed"), ",newValue", `expected [data-changed] to capture the callback arguments`) + this.assert.equal(this.controller.betaTargetAttributeChangedCallCountValue, 1) + this.assert.ok(beta.classList.contains("changed"), `expected "${beta.className}" to contain "changed"`) + } + + async "test does not call [target]AttributeChanged callback when a descendant's attribute changes"() { + const beta = this.findElement("#beta1") + const gamma = this.findElement("#gamma1") + + this.assert.equal(this.controller.betaTargetAttributeChangedCallCountValue, 0) + this.assert.notOk(beta.classList.contains("changed"), `expected "${beta.className}" not to contain "changed"`) + + gamma.setAttribute("data-changed", "value") + await this.nextFrame + + this.assert.equal(this.controller.betaTargetAttributeChangedCallCountValue, 0) + this.assert.notOk(beta.classList.contains("changed"), `expected "${beta.className}" not to contain "changed"`) + } }