Skip to content

Commit

Permalink
Prevent infinite looping in target callbacks (#459)
Browse files Browse the repository at this point in the history
Prior to this change, adding or removing target elements could result in
an infinite loop of callbacks and mutations.

This commit introduces `ElementObserver.pause()` (and transitively
introduces `AttributeObserver.pause()` and `TokenListObserver.pause()`)
to provide callers with the ability to pause and resume mutation
observation.

When firing `[name]TargetConnected` and `[name]TargetDisconnected`
callbacks, the behind-the-scenes observers are paused.
  • Loading branch information
seanpdoyle authored Oct 7, 2021
1 parent 3c5ede4 commit 3128ef1
Show file tree
Hide file tree
Showing 7 changed files with 57 additions and 5 deletions.
5 changes: 5 additions & 0 deletions docs/reference/targets.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ export default class extends Controller {
}
```

**Note** During the execution of `[name]TargetConnected` and
`[name]TargetDisconnected` callbacks, the `MutationObserver` instances behind
the scenes are paused. This means that if a callback add or removes a target
with a matching name, the corresponding callback _will not_ be invoked again.

## Naming Conventions

Always use camelCase to specify target names, since they map directly to properties on your controller.
4 changes: 2 additions & 2 deletions src/core/target_observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,14 @@ export class TargetObserver implements TokenListObserverDelegate {
connectTarget(element: Element, name: string) {
if (!this.targetsByName.has(name, element)) {
this.targetsByName.add(name, element)
this.delegate.targetConnected(element, name)
this.tokenListObserver?.pause(() => this.delegate.targetConnected(element, name))
}
}

disconnectTarget(element: Element, name: string) {
if (this.targetsByName.has(name, element)) {
this.targetsByName.delete(name, element)
this.delegate.targetDisconnected(element, name)
this.tokenListObserver?.pause(() => this.delegate.targetDisconnected(element, name))
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/mutation-observers/attribute_observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ export class AttributeObserver implements ElementObserverDelegate {
this.elementObserver.start()
}

pause(callback: () => void) {
this.elementObserver.pause(callback)
}

stop() {
this.elementObserver.stop()
}
Expand Down
17 changes: 16 additions & 1 deletion src/mutation-observers/element_observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export class ElementObserver {

private elements: Set<Element>
private mutationObserver: MutationObserver
private mutationObserverInit = { attributes: true, childList: true, subtree: true }

constructor(element: Element, delegate: ElementObserverDelegate) {
this.element = element
Expand All @@ -27,11 +28,25 @@ export class ElementObserver {
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
4 changes: 4 additions & 0 deletions src/mutation-observers/token_list_observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ export class TokenListObserver implements AttributeObserverDelegate {
this.attributeObserver.start()
}

pause(callback: () => void) {
this.attributeObserver.pause(callback)
}

stop() {
this.attributeObserver.stop()
}
Expand Down
17 changes: 15 additions & 2 deletions src/tests/controllers/target_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ class BaseTargetController extends Controller {

export class TargetController extends BaseTargetController {
static classes = [ "connected", "disconnected" ]
static targets = [ "beta", "input" ]
static values = { inputTargetConnectedCallCount: Number, inputTargetDisconnectedCallCount: Number }
static targets = [ "beta", "input", "recursive" ]
static values = { inputTargetConnectedCallCount: Number, inputTargetDisconnectedCallCount: Number, recursiveTargetConnectedCallCount: Number, recursiveTargetDisconnectedCallCount: Number }

betaTarget!: Element | null
betaTargets!: Element[]
Expand All @@ -28,6 +28,8 @@ export class TargetController extends BaseTargetController {

inputTargetConnectedCallCountValue = 0
inputTargetDisconnectedCallCountValue = 0
recursiveTargetConnectedCallCountValue = 0
recursiveTargetDisconnectedCallCountValue = 0

inputTargetConnected(element: Element) {
if (this.hasConnectedClass) element.classList.add(this.connectedClass)
Expand All @@ -38,4 +40,15 @@ export class TargetController extends BaseTargetController {
if (this.hasDisconnectedClass) element.classList.add(this.disconnectedClass)
this.inputTargetDisconnectedCallCountValue++
}

recursiveTargetConnected(element: Element) {
element.remove()

this.recursiveTargetConnectedCallCountValue++
this.element.append(element)
}

recursiveTargetDisconnected(element: Element) {
this.recursiveTargetDisconnectedCallCountValue++
}
}
11 changes: 11 additions & 0 deletions src/tests/modules/core/target_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,4 +164,15 @@ 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 [target]Connected() and [target]Disconnected() do not loop infinitely"() {
this.controller.element.insertAdjacentHTML("beforeend", `
<div data-${this.identifier}-target="recursive" id="recursive2"></div>
`)
await this.nextFrame

this.assert.ok(!!this.fixtureElement.querySelector("#recursive2"))
this.assert.equal(this.controller.recursiveTargetConnectedCallCountValue, 1)
this.assert.equal(this.controller.recursiveTargetDisconnectedCallCountValue, 0)
}
}

0 comments on commit 3128ef1

Please sign in to comment.