From df52b479f572c125fe4224f8f99b1a044046d3af Mon Sep 17 00:00:00 2001 From: Keith Cirkel Date: Sat, 31 Oct 2020 00:46:11 +0000 Subject: [PATCH 1/3] feat(listenForBind): automatically call on controller .ownerdocument --- src/bind.ts | 1 + test/bind.js | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/bind.ts b/src/bind.ts index bfbe7246..79299b18 100644 --- a/src/bind.ts +++ b/src/bind.ts @@ -11,6 +11,7 @@ export function bind(controller: HTMLElement): void { listenForBind(controller.shadowRoot) } bindElements(controller) + listenForBind(controller.ownerDocument) } /** diff --git a/test/bind.js b/test/bind.js index 94976346..198d7e48 100644 --- a/test/bind.js +++ b/test/bind.js @@ -138,6 +138,30 @@ describe('bind', () => { expect(instance.foo).to.have.been.called.exactly(2) }) + it('binds elements added to elements subtree', async () => { + const instance = document.createElement('bind-test-element') + chai.spy.on(instance, 'foo') + const el1 = document.createElement('div') + const el2 = document.createElement('div') + el1.setAttribute('data-action', 'click:bind-test-element#foo') + el2.setAttribute('data-action', 'submit:bind-test-element#foo') + document.body.appendChild(instance) + + bind(instance) + + instance.append(el1, el2) + // We need to wait for a couple of frames after injecting the HTML into to + // controller so that the actions have been bound to the controller. + await waitForNextAnimationFrame() + document.body.removeChild(instance) + + expect(instance.foo).to.have.not.been.called() + el1.click() + expect(instance.foo).to.have.been.called.exactly(1) + el2.dispatchEvent(new CustomEvent('submit')) + expect(instance.foo).to.have.been.called.exactly(2) + }) + it('can bind elements within the shadowDOM', () => { const instance = document.createElement('bind-test-element') chai.spy.on(instance, 'foo') @@ -199,6 +223,7 @@ describe('bind', () => { chai.spy.on(instance, 'foo') root.appendChild(instance) listenForBind(root).unsubscribe() + listenForBind(document).unsubscribe() const button = document.createElement('button') button.setAttribute('data-action', 'click:bind-test-element#foo') instance.appendChild(button) From 5e7f2bf7230faac0a17026d14c436a7b76168258 Mon Sep 17 00:00:00 2001 From: Keith Cirkel Date: Sat, 31 Oct 2020 00:46:41 +0000 Subject: [PATCH 2/3] fix(listenForBind): memoize calls based on given node This ensures only one listener is added per node. --- src/bind.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/bind.ts b/src/bind.ts index 79299b18..d19d7576 100644 --- a/src/bind.ts +++ b/src/bind.ts @@ -14,6 +14,7 @@ export function bind(controller: HTMLElement): void { listenForBind(controller.ownerDocument) } +const observers = new WeakMap() /** * Set up observer that will make sure any actions that are dynamically * injected into `el` will be bound to it's controller. @@ -22,6 +23,7 @@ export function bind(controller: HTMLElement): void { * stop further live updates. */ export function listenForBind(el: Node = document): Subscription { + if (observers.has(el)) return observers.get(el)! let closed = false const observer = new MutationObserver(mutations => { for (const mutation of mutations) { @@ -37,15 +39,18 @@ export function listenForBind(el: Node = document): Subscription { } }) observer.observe(el, {childList: true, subtree: true, attributes: true, attributeFilter: ['data-action']}) - return { + const subscription = { get closed() { return closed }, unsubscribe() { closed = true + observers.delete(el) observer.disconnect() } } + observers.set(el, subscription) + return subscription } interface Subscription { From bc0d93249e039d05f0962cf52a2581b566d2aa6b Mon Sep 17 00:00:00 2001 From: Keith Cirkel Date: Sat, 31 Oct 2020 00:48:01 +0000 Subject: [PATCH 3/3] docs(listenForBind): update docs to clarify auto-binding --- docs/_guide/actions.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/_guide/actions.md b/docs/_guide/actions.md index 6452973c..c24ac742 100644 --- a/docs/_guide/actions.md +++ b/docs/_guide/actions.md @@ -160,14 +160,21 @@ class HelloWorldElement extends HTMLElement { ### Binding dynamically added actions -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. - -You can provide the element you'd like to observe as a first argument which will default to `document`. +Catalyst automatically listens for elements that are dynamically injected into the DOM, and will bind any element's `data-action` attributes. It does this by calling `listenForBind(controller.ownerDocument)`. If for some reason you need to observe other documents (such as mutations within an iframe), then you can call the `listenForBind` manually, passing a `Node` to listen to DOM mutations on. Batch processing binds events in small batches to maintain UI stability (using `requestAnimationFrame` behind the scenes). ```js import {listenForBind} from '@github/catalyst' -listenForBind(document) +@controller +class HelloWorldElement extends HTMLElement { + @target iframe: HTMLIFrameElement + + connectedCallback() { + // listenForBind(this.ownerDocument) is automatically called. + + listenForBind(this.iframe.document.body) + } +} ```