Skip to content

Commit

Permalink
Outlets: Add observers for controller element attributes
Browse files Browse the repository at this point in the history
With the current Outlet implementation, they're only ever connected or
disconnected when an element _matching_ the outlet selector connects or
disconnects. The selector _declared on the controller_ element can only
ever be set once: when it's connected. If that attribute ever changes
(for example, when it's set to a new value or removed entirely), the
outlets are not updated.

This commit adds test coverage to ensure the list of outlets and their
items is synchronized with changes on both sides: when matching elements
are connected and disconnected _as well as_ when the selector that
dictates whether or not they match is added, updated, or removed.

To do so, this commit extends the `SelectorObserver` to also manage the
lifecycle of an `AttributeObserver` instance alongside its internally
managed `ElementObserver`.
  • Loading branch information
seanpdoyle committed Dec 11, 2022
1 parent 6b3054f commit e23e112
Show file tree
Hide file tree
Showing 5 changed files with 164 additions and 40 deletions.
12 changes: 6 additions & 6 deletions src/core/outlet_observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ export class OutletObserver implements SelectorObserverDelegate {
start() {
if (this.selectorObserverMap.size === 0) {
this.outletDefinitions.forEach((outletName) => {
const selector = this.selector(outletName)
const element = this.scope.element
const attributeName = this.outletAttributeFor(outletName)
const details: SelectorObserverDetails = { outletName }
const selectorObserver = new SelectorObserver(element, attributeName, document.body, this, details)

if (selector) {
this.selectorObserverMap.set(outletName, new SelectorObserver(document.body, selector, this, details))
}
this.selectorObserverMap.set(outletName, selectorObserver)
})

this.selectorObserverMap.forEach((observer) => observer.start())
Expand Down Expand Up @@ -113,8 +113,8 @@ export class OutletObserver implements SelectorObserverDelegate {

// Private

private selector(outletName: string) {
return this.scope.outlets.getSelectorForOutletName(outletName)
private outletAttributeFor(outletName: string) {
return this.context.schema.outletAttributeForScope(this.identifier, outletName)
}

private get outletDependencies() {
Expand Down
126 changes: 93 additions & 33 deletions src/mutation-observers/selector_observer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AttributeObserver, AttributeObserverDelegate } from "./attribute_observer"
import { ElementObserver, ElementObserverDelegate } from "./element_observer"
import { Multimap } from "../multimap"

Expand All @@ -7,17 +8,25 @@ export interface SelectorObserverDelegate {
selectorMatchElement?(element: Element, details: object): boolean
}

export class SelectorObserver implements ElementObserverDelegate {
private selector: string
private elementObserver: ElementObserver
private delegate: SelectorObserverDelegate
private matchesByElement: Multimap<string, Element>
private details: object

constructor(element: Element, selector: string, delegate: SelectorObserverDelegate, details: object = {}) {
this.selector = selector
export class SelectorObserver implements AttributeObserverDelegate, ElementObserverDelegate {
private readonly attributeObserver: AttributeObserver
private readonly elementObserver: ElementObserver
private readonly delegate: SelectorObserverDelegate
private readonly matchesByElement: Multimap<string, Element>
private readonly details: object
private selector: string | null

constructor(
element: Element,
attributeName: string,
scope: Element,
delegate: SelectorObserverDelegate,
details: object
) {
this.details = details
this.elementObserver = new ElementObserver(element, this)
this.attributeObserver = new AttributeObserver(element, attributeName, this)
this.selector = element.getAttribute(this.attributeName)
this.elementObserver = new ElementObserver(scope, this)
this.delegate = delegate
this.matchesByElement = new Multimap()
}
Expand All @@ -28,68 +37,119 @@ export class SelectorObserver implements ElementObserverDelegate {

start() {
this.elementObserver.start()
this.attributeObserver.start()
}

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

stop() {
this.attributeObserver.stop()
this.elementObserver.stop()
}

refresh() {
this.attributeObserver.refresh()
this.elementObserver.refresh()
}

// Attribute observer delegate

elementMatchedAttribute(controllerElement: Element) {
this.selector = controllerElement.getAttribute(this.attributeName)

this.elementObserver.refresh()
}

get element(): Element {
return this.elementObserver.element
elementUnmatchedAttribute() {
if (this.selector) {
const matchedElements = this.matchesByElement.getValuesForKey(this.selector)

for (const matchedElement of matchedElements) {
this.selectorUnmatched(matchedElement, this.selector)
}
}

this.selector = null
}

elementAttributeValueChanged(controllerElement: Element) {
this.elementMatchedAttribute(controllerElement)
}

// Element observer delegate

matchElement(element: Element): boolean {
const matches = element.matches(this.selector)
const { selector } = this

if (this.delegate.selectorMatchElement) {
return matches && this.delegate.selectorMatchElement(element, this.details)
}
if (selector) {
const matches = element.matches(selector)

if (this.delegate.selectorMatchElement) {
return matches && this.delegate.selectorMatchElement(element, this.details)
}

return matches
return matches
} else {
return false
}
}

matchElementsInTree(tree: Element): Element[] {
const match = this.matchElement(tree) ? [tree] : []
const matches = Array.from(tree.querySelectorAll(this.selector)).filter((match) => this.matchElement(match))
return match.concat(matches)
const { selector } = this

if (selector) {
const match = this.matchElement(tree) ? [tree] : []
const matches = Array.from(tree.querySelectorAll(selector)).filter((match) => this.matchElement(match))
return match.concat(matches)
} else {
return []
}
}

elementMatched(element: Element) {
this.selectorMatched(element)
const { selector } = this

if (selector) {
this.selectorMatched(element, selector)
}
}

elementUnmatched(element: Element) {
this.selectorUnmatched(element)
const { selector } = this

if (selector) {
this.selectorUnmatched(element, selector)
}
}

elementAttributeChanged(element: Element, _attributeName: string) {
const matches = this.matchElement(element)
const matchedBefore = this.matchesByElement.has(this.selector, element)
const { selector } = this

if (selector) {
const matches = this.matchElement(element)
const matchedBefore = this.matchesByElement.has(selector, element)

if (!matches && matchedBefore) {
this.selectorUnmatched(element)
if (!matches && matchedBefore) {
this.selectorUnmatched(element, selector)
}
}
}

private selectorMatched(element: Element) {
private selectorMatched(element: Element, selector: string) {
if (this.delegate.selectorMatched) {
this.delegate.selectorMatched(element, this.selector, this.details)
this.matchesByElement.add(this.selector, element)
this.delegate.selectorMatched(element, selector, this.details)
this.matchesByElement.add(selector, element)
}
}

private selectorUnmatched(element: Element) {
this.delegate.selectorUnmatched(element, this.selector, this.details)
this.matchesByElement.delete(this.selector, element)
private selectorUnmatched(element: Element, selector: string) {
this.delegate.selectorUnmatched(element, selector, this.details)
this.matchesByElement.delete(selector, element)
}

private get attributeName() {
return this.attributeObserver.attributeName
}
}
7 changes: 7 additions & 0 deletions src/tests/controllers/outlet_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export class OutletController extends BaseOutletController {
alphaOutletDisconnectedCallCount: Number,
betaOutletConnectedCallCount: Number,
betaOutletDisconnectedCallCount: Number,
gammaOutletConnectedCallCount: Number,
namespacedEpsilonOutletConnectedCallCount: Number,
namespacedEpsilonOutletDisconnectedCallCount: Number,
}
Expand All @@ -44,6 +45,7 @@ export class OutletController extends BaseOutletController {
alphaOutletDisconnectedCallCountValue = 0
betaOutletConnectedCallCountValue = 0
betaOutletDisconnectedCallCountValue = 0
gammaOutletConnectedCallCountValue = 0
namespacedEpsilonOutletConnectedCallCountValue = 0
namespacedEpsilonOutletDisconnectedCallCountValue = 0

Expand All @@ -67,6 +69,11 @@ export class OutletController extends BaseOutletController {
this.betaOutletDisconnectedCallCountValue++
}

gammaOutletConnected(_outlet: Controller, element: Element) {
if (this.hasConnectedClass) element.classList.add(this.connectedClass)
this.gammaOutletConnectedCallCountValue++
}

namespacedEpsilonOutletConnected(_outlet: Controller, element: Element) {
if (this.hasConnectedClass) element.classList.add(this.connectedClass)
this.namespacedEpsilonOutletConnectedCallCountValue++
Expand Down
50 changes: 50 additions & 0 deletions src/tests/modules/core/outlet_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,4 +297,54 @@ export default class OutletTests extends ControllerTestCase(OutletController) {
)
this.assert.ok(element.isConnected, "element is still present in document")
}

async "test outlet connect callback when the controlled element's outlet attribute is added"() {
const gamma2 = this.findElement("#gamma2")

await this.setAttribute(this.controller.element, `data-${this.identifier}-gamma-outlet`, "#gamma2")

this.assert.equal(this.controller.gammaOutletConnectedCallCountValue, 1)
this.assert.ok(gamma2.isConnected, "#gamma2 is still present in document")
this.assert.ok(gamma2.classList.contains("connected"), `expected "${gamma2.className}" to contain "connected"`)
}

async "test outlet connect callback when the controlled element's outlet attribute is changed"() {
const alpha1 = this.findElement("#alpha1")
const alpha2 = this.findElement("#alpha2")

await this.setAttribute(this.controller.element, `data-${this.identifier}-alpha-outlet`, "#alpha1")

this.assert.equal(this.controller.alphaOutletConnectedCallCountValue, 2)
this.assert.equal(this.controller.alphaOutletDisconnectedCallCountValue, 1)
this.assert.ok(alpha1.isConnected, "alpha1 is still present in document")
this.assert.ok(alpha2.isConnected, "alpha2 is still present in document")
this.assert.ok(alpha1.classList.contains("connected"), `expected "${alpha1.className}" to contain "connected"`)
this.assert.notOk(
alpha1.classList.contains("disconnected"),
`expected "${alpha1.className}" to contain "disconnected"`
)
this.assert.ok(
alpha2.classList.contains("disconnected"),
`expected "${alpha2.className}" to contain "disconnected"`
)
}

async "test outlet disconnected callback when the controlled element's outlet attribute is removed"() {
const alpha1 = this.findElement("#alpha1")
const alpha2 = this.findElement("#alpha2")

await this.removeAttribute(this.controller.element, `data-${this.identifier}-alpha-outlet`)

this.assert.equal(this.controller.alphaOutletDisconnectedCallCountValue, 2)
this.assert.ok(alpha1.isConnected, "#alpha1 is still present in document")
this.assert.ok(alpha2.isConnected, "#alpha2 is still present in document")
this.assert.ok(
alpha1.classList.contains("disconnected"),
`expected "${alpha1.className}" to contain "disconnected"`
)
this.assert.ok(
alpha2.classList.contains("disconnected"),
`expected "${alpha2.className}" to contain "disconnected"`
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { SelectorObserver, SelectorObserverDelegate } from "../../../mutation-ob
import { ObserverTestCase } from "../../cases/observer_test_case"

export default class SelectorObserverTests extends ObserverTestCase implements SelectorObserverDelegate {
selectorAttribute = "data-selector"
attributeName = "data-test"
selector = "div[data-test~=two]"
details = { some: "details" }
Expand All @@ -14,7 +15,13 @@ export default class SelectorObserverTests extends ObserverTestCase implements S
<span id="span2" ${this.attributeName}="two"></span>
</div>
`
observer = new SelectorObserver(this.fixtureElement, this.selector, this, this.details)

observer = new SelectorObserver(this.fixtureElement, this.selectorAttribute, this.fixtureElement, this, this.details)

async setup() {
await this.setAttribute(this.fixtureElement, this.selectorAttribute, this.selector)
await super.setup()
}

async "test should match when observer starts"() {
this.assert.deepEqual(this.calls, [
Expand Down

0 comments on commit e23e112

Please sign in to comment.