Skip to content

Commit

Permalink
Add element and target attribute change callbacks
Browse files Browse the repository at this point in the history
Closes #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
  • Loading branch information
seanpdoyle committed Sep 29, 2021
1 parent 53b2c69 commit 7a3e438
Show file tree
Hide file tree
Showing 8 changed files with 196 additions and 24 deletions.
2 changes: 2 additions & 0 deletions docs/reference/lifecycle_callbacks.md
Expand Up @@ -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

Expand Down
28 changes: 27 additions & 1 deletion src/core/context.ts
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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[]) {
Expand Down
38 changes: 36 additions & 2 deletions 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<string, Element>
private tokenListObserver?: TokenListObserver
private elementObserver?: ElementObserver

constructor(context: Context, delegate: TargetObserverDelegate) {
this.context = context
Expand All @@ -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() {
Expand All @@ -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
Expand All @@ -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) {
Expand All @@ -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() {
Expand Down
28 changes: 22 additions & 6 deletions src/mutation-observers/element_observer.ts
Expand Up @@ -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 {
Expand All @@ -14,24 +14,40 @@ export class ElementObserver {

private elements: Set<Element>
private mutationObserver: MutationObserver
private mutationObserverInit: MutationObserverInit

constructor(element: Element, delegate: ElementObserverDelegate) {
constructor(element: Element, delegate: ElementObserverDelegate, mutationObserverInit: Partial<MutationObserverInit> = {}) {
this.element = element
this.started = false
this.delegate = delegate

this.elements = new Set
this.mutationObserver = new MutationObserver((mutations) => this.processMutations(mutations))
this.mutationObserverInit = { attributes: true, childList: true, subtree: true, ...mutationObserverInit }
}

start() {
if (!this.started) {
this.started = true
this.mutationObserver.observe(this.element, { attributes: true, childList: true, subtree: true })
this.mutationObserver.observe(this.element, 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, this.mutationObserverInit)
this.started = true
}
}

stop() {
if (this.started) {
this.mutationObserver.takeRecords()
Expand Down Expand Up @@ -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)
}
Expand Down
25 changes: 25 additions & 0 deletions src/tests/controllers/log_controller.ts
Expand Up @@ -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++
Expand All @@ -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)
}
Expand Down Expand Up @@ -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,
})
}
}
21 changes: 15 additions & 6 deletions src/tests/controllers/target_controller.ts
Expand Up @@ -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[]
Expand All @@ -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(","))
}
}
32 changes: 32 additions & 0 deletions src/tests/modules/core/lifecycle_tests.ts
Expand Up @@ -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", `<div id="child"></div>`)
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()
Expand Down

0 comments on commit 7a3e438

Please sign in to comment.