Skip to content

Commit 41cdbda

Browse files
authored
Merge pull request #38 from github/bind-dynamically-injected-actions
Bind dynamically injected actions
2 parents 58390c6 + 6e16380 commit 41cdbda

6 files changed

Lines changed: 177 additions & 25 deletions

File tree

docs/_guide/actions.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,14 +144,26 @@ If you're using decorators, then the `@controller` decorator automatically handl
144144

145145
If you're not using decorators, then you'll need to call `bind(this)` somewhere inside of `connectedCallback()`.
146146

147-
```
147+
```js
148148
import {bind} from '@github/catalyst'
149149

150150
class HelloWorldElement extends HTMLElement {
151-
152151
connectedCallback() {
153152
bind(this)
154153
}
155-
156154
}
157155
```
156+
157+
### Binding dynamically added actions
158+
159+
Catalyst doesn't automatically bind actions to elements that are dynamically injected into the DOM. If you need to dynamically inject actions (for example you're injecting HTML via AJAX) you can call the `listenForBind` function to set up a observer that will bind actions when they are added to a controller.
160+
161+
You can provide the element you'd like to observe as a first argument and the number of items to process in a batch as a second argument. Those arguments default to `document` and `30` respectively.
162+
163+
Batch processing binds events in small batches to maintain UI stability (using `requestAnimationFrame` behind the scenes). We recommend the default of `30` as a sensible default, but you may find changing this number helps depending on your requirements.
164+
165+
```js
166+
import {listenForBind} from '@github/catalyst'
167+
168+
listenForBind(document, 30)
169+
```

docs/_layouts/guide.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
{% include sidebar.html %}
88

99
<section class="col-9 px-5 f4">
10-
<div class="container-md markdown-body">
10+
<div class="container-md markdown-body mb-5">
1111
<h1 class="mb-4 f0-light">{{ page.title }}</h1>
1212
{{ content }}
1313
</div>

src/bind.ts

Lines changed: 90 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
const bound = new Set<string>()
12
/*
23
* Bind `[data-action]` elements from the DOM to their actions.
34
*
45
*/
56
export function bind(controller: HTMLElement): void {
67
const tag = controller.tagName.toLowerCase()
8+
bound.add(tag)
79
const actionAttributeMatcher = `[data-action*=":${tag}#"]`
810

911
for (const el of controller.querySelectorAll(actionAttributeMatcher)) {
@@ -18,32 +20,103 @@ export function bind(controller: HTMLElement): void {
1820
}
1921
}
2022

21-
// Bind the data-action attribute of a single element to the controller
22-
function bindActionsToController(controller: HTMLElement, el: Element) {
23-
const tag = controller.tagName.toLowerCase()
24-
25-
// Match the pattern of `eventName:constructor#method`.
23+
// Match the pattern of `eventName:constructor#method`.
24+
function* getActions(el: Element): Generator<[string, string, string]> {
2625
for (const binding of (el.getAttribute('data-action') || '').split(' ')) {
27-
const [rest, method] = binding.split('#')
26+
const [rest, methodName] = binding.split('#')
27+
if (!methodName) continue
2828

2929
// eventName may contain `:` so account for that
3030
// by splitting by the last instance of `:`
3131
const colonIndex = rest.lastIndexOf(':')
3232
if (colonIndex < 0) continue
3333

34-
const handler = rest.slice(colonIndex + 1)
35-
if (handler !== tag) continue
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+
}
3649

37-
const eventName = rest.slice(0, colonIndex)
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()
3853

39-
// Check the `method` is present on the prototype
40-
const methodDescriptor =
41-
Object.getOwnPropertyDescriptor(controller, method) ||
42-
Object.getOwnPropertyDescriptor(Object.getPrototypeOf(controller), method)
43-
if (methodDescriptor && typeof methodDescriptor.value == 'function') {
44-
el.addEventListener(eventName, (event: Event) => {
45-
methodDescriptor.value.call(controller, event)
46-
})
54+
for (const [eventName, tagName, methodName] of getActions(el)) {
55+
if (tagName === tag) {
56+
bindActionToController(controller, el, methodName, eventName)
4757
}
4858
}
4959
}
60+
61+
interface Subscription {
62+
closed: boolean
63+
unsubscribe(): void
64+
}
65+
66+
/**
67+
* Set up observer that will make sure any actions that are dynamically
68+
* injected into `el` will be bound to it's controller.
69+
*
70+
* This returns a Subscription object which you can call `unsubscribe()` on to
71+
* stop further live updates.
72+
*/
73+
export function listenForBind(el = document, batchSize = 30): Subscription {
74+
let closed = false
75+
76+
const observer = new MutationObserver(mutations => {
77+
const queue = new Set<Element>()
78+
for (const mutation of mutations) {
79+
if (mutation.type === 'childList' && mutation.addedNodes.length) {
80+
for (const node of mutation.addedNodes) {
81+
if (!(node instanceof Element)) continue
82+
if (node.hasAttribute('data-action')) {
83+
queue.add(node)
84+
}
85+
}
86+
}
87+
}
88+
if (queue.size) requestAnimationFrame(() => processQueue(queue, batchSize))
89+
})
90+
91+
observer.observe(el, {childList: true, subtree: true})
92+
93+
return {
94+
get closed() {
95+
return closed
96+
},
97+
unsubscribe() {
98+
closed = true
99+
observer.disconnect()
100+
}
101+
}
102+
}
103+
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
111+
112+
bindActionToController(controller, el, methodName, eventName)
113+
}
114+
queue.delete(el)
115+
116+
counter -= 1
117+
if (counter === 0) break
118+
}
119+
if (queue.size !== 0) {
120+
requestAnimationFrame(() => processQueue(queue, batchSize))
121+
}
122+
}

src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import {bind} from './bind'
1+
import {bind, listenForBind} from './bind'
22
import {register} from './register'
33
import {findTarget, findTargets} from './findtarget'
44
import {target, targets} from './target'
55
import {controller} from './controller'
66

7-
export {bind, register, findTarget, findTargets, target, targets, controller}
7+
export {bind, listenForBind, register, findTarget, findTargets, target, targets, controller}

test/bind.js

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,25 @@
1-
import {bind} from '../lib/bind.js'
1+
import {bind, listenForBind} from '../lib/bind.js'
2+
3+
async function waitForNextAnimationFrame() {
4+
return new Promise(resolve => {
5+
window.requestAnimationFrame(resolve)
6+
})
7+
}
28

39
describe('bind', () => {
410
window.customElements.define('bind-test-element', class extends HTMLElement {})
511

12+
let root
13+
14+
beforeEach(() => {
15+
root = document.createElement('div')
16+
document.body.appendChild(root)
17+
})
18+
19+
afterEach(() => {
20+
root.remove()
21+
})
22+
623
it('queries for Elements matching data-action*="tagname"', () => {
724
const instance = document.createElement('bind-test-element')
825
chai.spy.on(instance, 'querySelectorAll', () => [])
@@ -138,4 +155,55 @@ describe('bind', () => {
138155
el2.addEventListener.__spy.calls[0][1]('b')
139156
expect(instance.foo).to.have.been.called.twice.second.with('b')
140157
})
158+
159+
describe('listenForBind', () => {
160+
it('re-binds actions that are denoted by HTML that is dynamically injected into the controller', async function () {
161+
const instance = document.createElement('bind-test-element')
162+
chai.spy.on(instance, 'foo')
163+
root.appendChild(instance)
164+
listenForBind(root)
165+
const button = document.createElement('button')
166+
button.setAttribute('data-action', 'click:bind-test-element#foo')
167+
instance.appendChild(button)
168+
// We need to wait for a couple of frames after injecting the HTML into to
169+
// controller so that the actions have been bound to the controller.
170+
await waitForNextAnimationFrame()
171+
await waitForNextAnimationFrame()
172+
button.click()
173+
expect(instance.foo).to.have.been.called.exactly(1)
174+
})
175+
176+
it('will not re-bind actions after unsubscribe() is called', async function () {
177+
const instance = document.createElement('bind-test-element')
178+
chai.spy.on(instance, 'foo')
179+
root.appendChild(instance)
180+
listenForBind(root).unsubscribe()
181+
const button = document.createElement('button')
182+
button.setAttribute('data-action', 'click:bind-test-element#foo')
183+
instance.appendChild(button)
184+
// We need to wait for a couple of frames after injecting the HTML into to
185+
// controller so that the actions have been bound to the controller.
186+
await waitForNextAnimationFrame()
187+
await waitForNextAnimationFrame()
188+
button.click()
189+
expect(instance.foo).to.have.been.called.exactly(0)
190+
})
191+
192+
it('will not re-bind elements that havent already had `bind()` called', async function () {
193+
customElements.define('bind-test-not-element', class BindTestNotController extends HTMLElement {})
194+
const instance = document.createElement('bind-test-not-element')
195+
chai.spy.on(instance, 'foo')
196+
root.appendChild(instance)
197+
listenForBind(root)
198+
const button = document.createElement('button')
199+
button.setAttribute('data-action', 'click:bind-test-not-element#foo')
200+
instance.appendChild(button)
201+
// We need to wait for a couple of frames after injecting the HTML into to
202+
// controller so that the actions have been bound to the controller.
203+
await waitForNextAnimationFrame()
204+
await waitForNextAnimationFrame()
205+
button.click()
206+
expect(instance.foo).to.have.been.called.exactly(0)
207+
})
208+
})
141209
})

test/findtarget.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ describe('findTarget', () => {
44
window.customElements.define('find-target-test-element', class extends HTMLElement {})
55

66
let root
7-
87
beforeEach(() => {
98
root = document.createElement('div')
109
document.body.appendChild(root)

0 commit comments

Comments
 (0)