Skip to content

Commit 9c8fb8d

Browse files
authored
Merge pull request #53 from github/fix-listenforbind-handle-children
Ground up rewrite of `bind`/`listenForBind`
2 parents 10eef6c + b45d3bc commit 9c8fb8d

2 files changed

Lines changed: 166 additions & 155 deletions

File tree

src/bind.ts

Lines changed: 50 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,12 @@
1-
const bound = new Set<string>()
1+
const controllers = new Set<string>()
2+
23
/*
34
* Bind `[data-action]` elements from the DOM to their actions.
45
*
56
*/
67
export function bind(controller: HTMLElement): void {
7-
const tag = controller.tagName.toLowerCase()
8-
bound.add(tag)
9-
const actionAttributeMatcher = `[data-action*=":${tag}#"]`
10-
11-
for (const el of controller.querySelectorAll(actionAttributeMatcher)) {
12-
// Ignore nested elements
13-
if (el.closest(tag) !== controller) continue
14-
bindActionsToController(controller, el)
15-
}
16-
17-
// Also bind the controller to itself
18-
if (controller.matches(actionAttributeMatcher)) {
19-
bindActionsToController(controller, controller)
20-
}
21-
}
22-
23-
// Match the pattern of `eventName:constructor#method`.
24-
function* getActions(el: Element): Generator<[string, string, string]> {
25-
for (const binding of (el.getAttribute('data-action') || '').split(' ')) {
26-
const [rest, methodName] = binding.split('#')
27-
if (!methodName) continue
28-
29-
// eventName may contain `:` so account for that
30-
// by splitting by the last instance of `:`
31-
const colonIndex = rest.lastIndexOf(':')
32-
if (colonIndex < 0) continue
33-
34-
yield [rest.slice(0, colonIndex), rest.slice(colonIndex + 1), methodName]
35-
}
36-
}
37-
38-
function bindActionToController(controller: HTMLElement, el: Element, methodName: string, eventName: string) {
39-
// Check the `method` is present on the prototype
40-
const methodDescriptor =
41-
Object.getOwnPropertyDescriptor(controller, methodName) ||
42-
Object.getOwnPropertyDescriptor(Object.getPrototypeOf(controller), methodName)
43-
if (methodDescriptor && typeof methodDescriptor.value == 'function') {
44-
el.addEventListener(eventName, (event: Event) => {
45-
methodDescriptor.value.call(controller, event)
46-
})
47-
}
48-
}
49-
50-
// Bind the data-action attribute of a single element to the controller
51-
function bindActionsToController(controller: HTMLElement, el: Element) {
52-
const tag = controller.tagName.toLowerCase()
53-
54-
for (const [eventName, tagName, methodName] of getActions(el)) {
55-
if (tagName === tag) {
56-
bindActionToController(controller, el, methodName, eventName)
57-
}
58-
}
59-
}
60-
61-
interface Subscription {
62-
closed: boolean
63-
unsubscribe(): void
8+
controllers.add(controller.tagName.toLowerCase())
9+
bindElements(controller)
6410
}
6511

6612
/**
@@ -70,26 +16,22 @@ interface Subscription {
7016
* This returns a Subscription object which you can call `unsubscribe()` on to
7117
* stop further live updates.
7218
*/
73-
export function listenForBind(el: Node = document, batchSize = 30): Subscription {
19+
export function listenForBind(el: Node = document): Subscription {
7420
let closed = false
75-
7621
const observer = new MutationObserver(mutations => {
77-
const queue = new Set<Element>()
7822
for (const mutation of mutations) {
79-
if (mutation.type === 'childList' && mutation.addedNodes.length) {
23+
if (mutation.type === 'attributes' && mutation.target instanceof Element) {
24+
bindActions(mutation.target)
25+
} else if (mutation.type === 'childList' && mutation.addedNodes.length) {
8026
for (const node of mutation.addedNodes) {
81-
if (!(node instanceof Element)) continue
82-
if (node.hasAttribute('data-action')) {
83-
queue.add(node)
27+
if (node instanceof Element) {
28+
bindElements(node)
8429
}
8530
}
8631
}
8732
}
88-
if (queue.size) requestAnimationFrame(() => processQueue(queue, batchSize))
8933
})
90-
91-
observer.observe(el, {childList: true, subtree: true})
92-
34+
observer.observe(el, {childList: true, subtree: true, attributes: true, attributeFilter: ['data-action']})
9335
return {
9436
get closed() {
9537
return closed
@@ -101,22 +43,48 @@ export function listenForBind(el: Node = document, batchSize = 30): Subscription
10143
}
10244
}
10345

104-
function processQueue(queue: Set<Element>, batchSize: number) {
105-
let counter = batchSize
106-
for (const el of queue) {
107-
for (const [eventName, controllerTag, methodName] of getActions(el)) {
108-
if (!bound.has(controllerTag)) continue
109-
const controller = el.closest(controllerTag)
110-
if (!(controller instanceof HTMLElement)) continue
46+
interface Subscription {
47+
closed: boolean
48+
unsubscribe(): void
49+
}
50+
51+
function bindElements(root: Element) {
52+
for (const el of root.querySelectorAll('[data-action]')) {
53+
bindActions(el)
54+
}
55+
// Also bind the controller to itself
56+
if (root.hasAttribute('data-action')) {
57+
bindActions(root)
58+
}
59+
}
11160

112-
bindActionToController(controller, el, methodName, eventName)
61+
// Bind a single function to all events to avoid anonymous closure performance penalty.
62+
function handleEvent(event: Event) {
63+
const el = event.currentTarget as Element
64+
for (const binding of bindings(el)) {
65+
if (event.type === binding.type && controllers.has(binding.tag)) {
66+
const controller = el.closest(binding.tag) as Element & Record<string, (ev: Event) => unknown>
67+
if (controller && typeof controller[binding.method] === 'function') {
68+
controller[binding.method](event)
69+
}
11370
}
114-
queue.delete(el)
71+
}
72+
}
11573

116-
counter -= 1
117-
if (counter === 0) break
74+
type Binding = {type: string; tag: string; method: string}
75+
function* bindings(el: Element): Iterable<Binding> {
76+
for (const action of (el.getAttribute('data-action') || '').split(' ')) {
77+
const eventSep = action.lastIndexOf(':')
78+
const methodSep = action.lastIndexOf('#')
79+
const type = action.slice(0, eventSep)
80+
const tag = action.slice(eventSep + 1, methodSep)
81+
const method = action.slice(methodSep + 1)
82+
yield {type, tag, method}
11883
}
119-
if (queue.size !== 0) {
120-
requestAnimationFrame(() => processQueue(queue, batchSize))
84+
}
85+
86+
function bindActions(el: Element) {
87+
for (const binding of bindings(el)) {
88+
el.addEventListener(binding.type, handleEvent)
12189
}
12290
}

0 commit comments

Comments
 (0)