Skip to content

Commit

Permalink
Outlets API (#576)
Browse files Browse the repository at this point in the history
* Introduce Controller Outlets

* Observe outlets to trigger outlet callbacks

* Add tests for Outlets

* Unify signatures for outlet callbacks

* Swap out `TokenListObserver` for new `SelectorObserver`

The approach using the `SelectorObserver` is much more reliable to fire
the outlet connected and disconnected callbacks.

It's now matching against the selector provided in the
`data-[controller]-[outlet]-outlet` attribute instead of just looking
for elements which have a `data-controller` attribute to appear.
Previously, when a `data-controller` appeared we checked it's attribute
value to see if it was relevant as an outlet for the current controller.

Using the `SelectorObserver` we now solve an edge case where the user
would dynamically add/remove an attribute to the outlet element which
would then make that element relevant or not relevant anymore as an
outlet for the current controller. We need to know that so that we can
reliably fire the outlet callbacks. Previously I wouldn't have fired the
callbacks.

For every outlet we define we now create a separate instance of the
`SelectorObserver` which also handles that we just match the relevant
outlets by adding the `data-controller~=[outletName]` selector to the
CSS selector we lookup.

* Use `outletName` over `name`

* Add `namespaceCamelize` helper and add tests for all string helpers

* Support Outlets for namespaced controllers

* Invoke outlet callbacks for instances that get connected after the current instance gets connected

There was an edge case where outlets wouldn't fire the outlet callbacks 
if they appeared later in the DOM but the current instance had a 
"dependency" on them.

To solve this the OutletObserver now "notifies" it's dependents that 
it conencted so they can refire the matching elements via the 
`refresh()` function of the ElementObserver.

* move `bodyScope` to getter and use `documentElement` instead of `body`
  • Loading branch information
marcoroth committed Nov 17, 2022
1 parent 9fed5df commit af88dbf
Show file tree
Hide file tree
Showing 14 changed files with 1,052 additions and 3 deletions.
22 changes: 21 additions & 1 deletion src/core/context.ts
Expand Up @@ -8,14 +8,17 @@ import { Schema } from "./schema"
import { Scope } from "./scope"
import { ValueObserver } from "./value_observer"
import { TargetObserver, TargetObserverDelegate } from "./target_observer"
import { OutletObserver, OutletObserverDelegate } from "./outlet_observer"
import { namespaceCamelize } from "./string_helpers"

export class Context implements ErrorHandler, TargetObserverDelegate {
export class Context implements ErrorHandler, TargetObserverDelegate, OutletObserverDelegate {
readonly module: Module
readonly scope: Scope
readonly controller: Controller
private bindingObserver: BindingObserver
private valueObserver: ValueObserver
private targetObserver: TargetObserver
private outletObserver: OutletObserver

constructor(module: Module, scope: Scope) {
this.module = module
Expand All @@ -24,6 +27,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.outletObserver = new OutletObserver(this, this)

try {
this.controller.initialize()
Expand All @@ -37,6 +41,7 @@ export class Context implements ErrorHandler, TargetObserverDelegate {
this.bindingObserver.start()
this.valueObserver.start()
this.targetObserver.start()
this.outletObserver.start()

try {
this.controller.connect()
Expand All @@ -46,6 +51,10 @@ export class Context implements ErrorHandler, TargetObserverDelegate {
}
}

refresh() {
this.outletObserver.refresh()
}

disconnect() {
try {
this.controller.disconnect()
Expand All @@ -54,6 +63,7 @@ export class Context implements ErrorHandler, TargetObserverDelegate {
this.handleError(error, "disconnecting controller")
}

this.outletObserver.stop()
this.targetObserver.stop()
this.valueObserver.stop()
this.bindingObserver.stop()
Expand Down Expand Up @@ -109,6 +119,16 @@ export class Context implements ErrorHandler, TargetObserverDelegate {
this.invokeControllerMethod(`${name}TargetDisconnected`, element)
}

// Outlet observer delegate

outletConnected(outlet: Controller, element: Element, name: string) {
this.invokeControllerMethod(`${namespaceCamelize(name)}OutletConnected`, outlet, element)
}

outletDisconnected(outlet: Controller, element: Element, name: string) {
this.invokeControllerMethod(`${namespaceCamelize(name)}OutletDisconnected`, outlet, element)
}

// Private

invokeControllerMethod(methodName: string, ...args: any[]) {
Expand Down
13 changes: 12 additions & 1 deletion src/core/controller.ts
Expand Up @@ -2,14 +2,21 @@ import { Application } from "./application"
import { ClassPropertiesBlessing } from "./class_properties"
import { Constructor } from "./constructor"
import { Context } from "./context"
import { OutletPropertiesBlessing } from "./outlet_properties"
import { TargetPropertiesBlessing } from "./target_properties"
import { ValuePropertiesBlessing, ValueDefinitionMap } from "./value_properties"

export type ControllerConstructor = Constructor<Controller>

export class Controller<ElementType extends Element = Element> {
static blessings = [ClassPropertiesBlessing, TargetPropertiesBlessing, ValuePropertiesBlessing]
static blessings = [
ClassPropertiesBlessing,
TargetPropertiesBlessing,
ValuePropertiesBlessing,
OutletPropertiesBlessing,
]
static targets: string[] = []
static outlets: string[] = []
static values: ValueDefinitionMap = {}

static get shouldLoad() {
Expand Down Expand Up @@ -46,6 +53,10 @@ export class Controller<ElementType extends Element = Element> {
return this.scope.targets
}

get outlets() {
return this.scope.outlets
}

get classes() {
return this.scope.classes
}
Expand Down
173 changes: 173 additions & 0 deletions src/core/outlet_observer.ts
@@ -0,0 +1,173 @@
import { Multimap } from "../multimap"
import { SelectorObserver, SelectorObserverDelegate } from "../mutation-observers"
import { Context } from "./context"
import { Controller } from "./controller"

import { readInheritableStaticArrayValues } from "./inheritable_statics"

type SelectorObserverDetails = { outletName: string }

export interface OutletObserverDelegate {
outletConnected(outlet: Controller, element: Element, outletName: string): void
outletDisconnected(outlet: Controller, element: Element, outletName: string): void
}

export class OutletObserver implements SelectorObserverDelegate {
readonly context: Context
readonly delegate: OutletObserverDelegate
readonly outletsByName: Multimap<string, Controller>
readonly outletElementsByName: Multimap<string, Element>
private selectorObserverMap: Map<string, SelectorObserver>

constructor(context: Context, delegate: OutletObserverDelegate) {
this.context = context
this.delegate = delegate
this.outletsByName = new Multimap()
this.outletElementsByName = new Multimap()
this.selectorObserverMap = new Map()
}

start() {
if (this.selectorObserverMap.size === 0) {
this.outletDefinitions.forEach((outletName) => {
const selector = this.selector(outletName)
const details: SelectorObserverDetails = { outletName }

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

this.selectorObserverMap.forEach((observer) => observer.start())
}

this.dependentContexts.forEach((context) => context.refresh())
}

stop() {
if (this.selectorObserverMap.size > 0) {
this.disconnectAllOutlets()
this.selectorObserverMap.forEach((observer) => observer.stop())
this.selectorObserverMap.clear()
}
}

refresh() {
this.selectorObserverMap.forEach((observer) => observer.refresh())
}

// Selector observer delegate

selectorMatched(element: Element, _selector: string, { outletName }: SelectorObserverDetails) {
const outlet = this.getOutlet(element, outletName)

if (outlet) {
this.connectOutlet(outlet, element, outletName)
}
}

selectorUnmatched(element: Element, _selector: string, { outletName }: SelectorObserverDetails) {
const outlet = this.getOutletFromMap(element, outletName)

if (outlet) {
this.disconnectOutlet(outlet, element, outletName)
}
}

selectorMatchElement(element: Element, { outletName }: SelectorObserverDetails) {
return (
this.hasOutlet(element, outletName) &&
element.matches(`[${this.context.application.schema.controllerAttribute}~=${outletName}]`)
)
}

// Outlet management

connectOutlet(outlet: Controller, element: Element, outletName: string) {
if (!this.outletElementsByName.has(outletName, element)) {
this.outletsByName.add(outletName, outlet)
this.outletElementsByName.add(outletName, element)
this.selectorObserverMap.get(outletName)?.pause(() => this.delegate.outletConnected(outlet, element, outletName))
}
}

disconnectOutlet(outlet: Controller, element: Element, outletName: string) {
if (this.outletElementsByName.has(outletName, element)) {
this.outletsByName.delete(outletName, outlet)
this.outletElementsByName.delete(outletName, element)
this.selectorObserverMap
.get(outletName)
?.pause(() => this.delegate.outletDisconnected(outlet, element, outletName))
}
}

disconnectAllOutlets() {
for (const outletName of this.outletElementsByName.keys) {
for (const element of this.outletElementsByName.getValuesForKey(outletName)) {
for (const outlet of this.outletsByName.getValuesForKey(outletName)) {
this.disconnectOutlet(outlet, element, outletName)
}
}
}
}

// Private

private selector(outletName: string) {
return this.scope.outlets.getSelectorForOutletName(outletName)
}

private get outletDependencies() {
const dependencies = new Multimap<string, string>()

this.router.modules.forEach((module) => {
const constructor = module.definition.controllerConstructor
const outlets = readInheritableStaticArrayValues(constructor, "outlets")

outlets.forEach((outlet) => dependencies.add(outlet, module.identifier))
})

return dependencies
}

private get outletDefinitions() {
return this.outletDependencies.getKeysForValue(this.identifier)
}

private get dependentControllerIdentifiers() {
return this.outletDependencies.getValuesForKey(this.identifier)
}

private get dependentContexts() {
const identifiers = this.dependentControllerIdentifiers
return this.router.contexts.filter((context) => identifiers.includes(context.identifier))
}

private hasOutlet(element: Element, outletName: string) {
return !!this.getOutlet(element, outletName) || !!this.getOutletFromMap(element, outletName)
}

private getOutlet(element: Element, outletName: string) {
return this.application.getControllerForElementAndIdentifier(element, outletName)
}

private getOutletFromMap(element: Element, outletName: string) {
return this.outletsByName.getValuesForKey(outletName).find((outlet) => outlet.element === element)
}

private get scope() {
return this.context.scope
}

private get identifier() {
return this.context.identifier
}

private get application() {
return this.context.application
}

private get router() {
return this.application.router
}
}
83 changes: 83 additions & 0 deletions src/core/outlet_properties.ts
@@ -0,0 +1,83 @@
import { Constructor } from "./constructor"
import { Controller } from "./controller"
import { readInheritableStaticArrayValues } from "./inheritable_statics"
import { capitalize, namespaceCamelize } from "./string_helpers"

export function OutletPropertiesBlessing<T>(constructor: Constructor<T>) {
const outlets = readInheritableStaticArrayValues(constructor, "outlets")
return outlets.reduce((properties: any, outletDefinition: any) => {
return Object.assign(properties, propertiesForOutletDefinition(outletDefinition))
}, {} as PropertyDescriptorMap)
}

function propertiesForOutletDefinition(name: string) {
const camelizedName = namespaceCamelize(name)

return {
[`${camelizedName}Outlet`]: {
get(this: Controller) {
const outlet = this.outlets.find(name)

if (outlet) {
const outletController = this.application.getControllerForElementAndIdentifier(outlet, name)
if (outletController) {
return outletController
} else {
throw new Error(
`Missing "data-controller=${name}" attribute on outlet element for "${this.identifier}" controller`
)
}
}

throw new Error(`Missing outlet element "${name}" for "${this.identifier}" controller`)
},
},

[`${camelizedName}Outlets`]: {
get(this: Controller) {
const outlets = this.outlets.findAll(name)

if (outlets.length > 0) {
return outlets
.map((outlet: Element) => {
const controller = this.application.getControllerForElementAndIdentifier(outlet, name)
if (controller) {
return controller
} else {
console.warn(
`The provided outlet element is missing the outlet controller "${name}" for "${this.identifier}"`,
outlet
)
}
})
.filter((controller) => controller) as Controller[]
}

return []
},
},

[`${camelizedName}OutletElement`]: {
get(this: Controller) {
const outlet = this.outlets.find(name)
if (outlet) {
return outlet
} else {
throw new Error(`Missing outlet element "${name}" for "${this.identifier}" controller`)
}
},
},

[`${camelizedName}OutletElements`]: {
get(this: Controller) {
return this.outlets.findAll(name)
},
},

[`has${capitalize(camelizedName)}Outlet`]: {
get(this: Controller) {
return this.outlets.has(name)
},
},
}
}

0 comments on commit af88dbf

Please sign in to comment.