diff --git a/docs/ui-binding.md b/docs/ui-binding.md index 5b170a5..d6b2370 100644 --- a/docs/ui-binding.md +++ b/docs/ui-binding.md @@ -127,3 +127,18 @@ naja.uiHandler.addEventListener('interaction', (event) => { } }); ``` + + +## Delegated binding + +Since version 3.3.0, you can also opt into event delegation for Naja's UI binding. That way, instead of binding directly to `.ajax` elements, Naja binds itself to `body`, listens to all clicks and form submissions, and only then decides whether to act upon them, or let the browser handle them. + +This might decrease time to interactive in situations where you have a huge number of `.ajax` elements on a page, because Naja doesn't have to iterate over so many interaction targets. On the other hand, this may introduce unnecessary function calls if you have a lot of non-AJAX interactivity within your application. You are advised to do your own benchmark and decide accordingly. + +You can switch to delegated binding this way: + +```js +naja.uiHandler.binding = 'delegated'; +``` + +This has to be configured before Naja is initialized and cannot be changed later. diff --git a/src/core/UIHandler.ts b/src/core/UIHandler.ts index 43ea1dd..4e469af 100644 --- a/src/core/UIHandler.ts +++ b/src/core/UIHandler.ts @@ -6,49 +6,35 @@ export class UIHandler extends EventTarget { public allowedOrigins: (string | URL)[] = [window.location.origin]; private handler = this.handleUI.bind(this); + public binding: BindingStrategyType = 'direct'; + private _bindingStrategy: BindingStrategy|undefined; + private readonly bindingStrategies: Record; + + private get bindingStrategy() { + this._bindingStrategy ??= this.bindingStrategies[this.binding]; + return this._bindingStrategy; + } + public constructor(private readonly naja: Naja) { super(); + + this.bindingStrategies = { + direct: new DirectBindingStrategy(naja), + delegated: new DelegatedBindingStrategy(naja), + }; + naja.addEventListener('init', this.initialize.bind(this)); } private initialize(): void { - onDomReady(() => this.bindUI(window.document.body)); - this.naja.snippetHandler.addEventListener('afterUpdate', (event) => { - const {snippet} = event.detail; - this.bindUI(snippet); - }); + this.bindingStrategy.initialize(this.handler); } public bindUI(element: Element): void { - const selector = `a${this.selector}`; - - const bindElement = (element: HTMLAnchorElement) => { - element.removeEventListener('click', this.handler); - element.addEventListener('click', this.handler); - }; - - if (element.matches(selector)) { - return bindElement(element as HTMLAnchorElement); - } - - const elements = element.querySelectorAll(selector); - elements.forEach((element) => bindElement(element as HTMLAnchorElement)); - - const bindForm = (form: HTMLFormElement) => { - form.removeEventListener('submit', this.handler); - form.addEventListener('submit', this.handler); - }; - - if (element instanceof HTMLFormElement) { - return bindForm(element); - } - - const forms = element.querySelectorAll('form'); - forms.forEach((form) => bindForm(form)); + this.bindingStrategy.bind(element); } - private handleUI(event: MouseEvent | SubmitEvent): void { - const element = event.currentTarget as HTMLElement; + private handleUI(element: HTMLElement, event: MouseEvent | SubmitEvent): void { const options = this.naja.prepareOptions(); const ignoreErrors = () => { @@ -148,3 +134,101 @@ export type InteractionEvent = CustomEvent<{element: Element, originalEvent?: Ev interface UIHandlerEventMap { interaction: InteractionEvent; } + +type BindingStrategyType = 'direct' | 'delegated'; + +interface BindingStrategy { + readonly type: BindingStrategyType; + initialize(handler: (target: HTMLElement, event: MouseEvent | SubmitEvent) => unknown): void; + bind(element: Element): void; +} + +class DirectBindingStrategy implements BindingStrategy { + public readonly type = 'direct'; + + private eventListener: (event: MouseEvent | SubmitEvent) => unknown = () => { + throw new Error('UIHandler: binding strategy must be initialized first.'); + }; + + // eslint-disable-next-line no-empty-function + public constructor(private readonly naja: Naja) {} + + public initialize( + handler: (target: HTMLElement, event: MouseEvent | SubmitEvent) => unknown, + ): void { + this.eventListener = (event: MouseEvent | SubmitEvent) => handler(event.currentTarget as HTMLElement, event); + + onDomReady(() => this.bind(window.document.body)); + this.naja.snippetHandler.addEventListener('afterUpdate', (event) => { + const {snippet} = event.detail; + this.bind(snippet); + }); + } + + public bind(element: Element): void { + const linkSelector = `a${this.naja.uiHandler.selector}`; + if (element.matches(linkSelector)) { + (element as HTMLAnchorElement).removeEventListener('click', this.eventListener); + (element as HTMLAnchorElement).addEventListener('click', this.eventListener); + return; + } + + const links = element.querySelectorAll(linkSelector); + links.forEach((link) => { + (link as HTMLAnchorElement).removeEventListener('click', this.eventListener); + (link as HTMLAnchorElement).addEventListener('click', this.eventListener); + }); + + if (element instanceof HTMLFormElement) { + element.removeEventListener('submit', this.eventListener); + element.addEventListener('submit', this.eventListener); + return; + } + + const forms = element.querySelectorAll('form'); + forms.forEach((form) => { + form.removeEventListener('submit', this.eventListener); + form.addEventListener('submit', this.eventListener); + }); + } +} + +class DelegatedBindingStrategy implements BindingStrategy { + public readonly type = 'delegated'; + + private clickEventListener: (event: MouseEvent) => unknown = () => { + throw new Error('UIHandler: binding strategy must be initialized first.'); + }; + + private submitEventListener: (event: SubmitEvent) => unknown = () => { + throw new Error('UIHandler: binding strategy must be initialized first.'); + }; + + // eslint-disable-next-line no-empty-function + public constructor(private readonly naja: Naja) {} + + public initialize( + handler: (target: HTMLElement, event: MouseEvent | SubmitEvent) => unknown, + ): void { + this.clickEventListener = (event: MouseEvent) => { + const linkSelector = `a${this.naja.uiHandler.selector}`; + const target = (event.target as HTMLElement).closest(linkSelector); + if (target !== null) { + handler(target as HTMLAnchorElement, event); + } + }; + + this.submitEventListener = (event: SubmitEvent) => { + handler(event.target as HTMLFormElement, event); + }; + + onDomReady(() => { + window.document.body.addEventListener('click', this.clickEventListener); + window.document.body.addEventListener('submit', this.submitEventListener); + }); + } + + public bind(): void { + // no-op + } +} diff --git a/tests/Naja.UIHandler.js b/tests/Naja.UIHandler.js index ad1ce36..266526b 100644 --- a/tests/Naja.UIHandler.js +++ b/tests/Naja.UIHandler.js @@ -85,98 +85,124 @@ describe('UIHandler', function () { mock.verify(); }); - describe('bindUI()', function () { - it('binds to .ajax elements by default', function () { - const naja = mockNaja({ - snippetHandler: SnippetHandler, - uiHandler: UIHandler, - }); - naja.uiHandler.handler = sinon.spy((evt) => evt.preventDefault()); - naja.initialize(); - - this.a.dispatchEvent(new MouseEvent('click', {cancelable: true})); - this.form.dispatchEvent(new SubmitEvent('submit', {cancelable: true})); - this.form.requestSubmit(); + ['direct', 'delegated'].forEach((bindingStrategy) => { + describe(`UI binding – ${bindingStrategy}`, function () { + function cleanUp(naja) { + if (bindingStrategy === 'delegated') { + document.body.removeEventListener('click', naja.uiHandler.bindingStrategy.clickEventListener); + document.body.removeEventListener('submit', naja.uiHandler.bindingStrategy.submitEventListener); + } + } + + it('binds to .ajax elements by default', function () { + const naja = mockNaja({ + snippetHandler: SnippetHandler, + uiHandler: UIHandler, + }); + naja.uiHandler.binding = bindingStrategy; + naja.uiHandler.handler = sinon.spy((_, evt) => evt.preventDefault()); + naja.initialize(); - assert.equal(naja.uiHandler.handler.callCount, 3); - }); + this.a.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true})); + this.form.dispatchEvent(new SubmitEvent('submit', {bubbles: true, cancelable: true})); + this.form.requestSubmit(); - it('binds to elements specified by custom selector', function () { - const customSelectorLink = document.createElement('a'); - customSelectorLink.href = '/UIHandler/customSelector'; - customSelectorLink.setAttribute('data-naja', true); - document.body.appendChild(customSelectorLink); + assert.equal(naja.uiHandler.handler.callCount, 3); + assert.isTrue(naja.uiHandler.handler.firstCall.calledWith(this.a)); + assert.isTrue(naja.uiHandler.handler.secondCall.calledWith(this.form)); - const naja = mockNaja({ - snippetHandler: SnippetHandler, - uiHandler: UIHandler, + cleanUp(naja); }); - naja.uiHandler.selector = '[data-naja]'; - naja.uiHandler.handler = sinon.spy((evt) => evt.preventDefault()); - naja.initialize(); - customSelectorLink.dispatchEvent(new MouseEvent('click', {cancelable: true})); - assert.isTrue(naja.uiHandler.handler.called); - document.body.removeChild(customSelectorLink); - }); - - it('binds to all elements when selector is empty', function () { - const emptySelectorLink = document.createElement('a'); - emptySelectorLink.href = '/UIHandler/emptySelector'; - document.body.appendChild(emptySelectorLink); + it('binds to elements specified by custom selector', function () { + const customSelectorLink = document.createElement('a'); + customSelectorLink.href = '/UIHandler/customSelector'; + customSelectorLink.setAttribute('data-naja', true); + document.body.appendChild(customSelectorLink); - const emptySelectorForm = document.createElement('form'); - emptySelectorForm.action = '/UIHandler/emptySelector'; - document.body.appendChild(emptySelectorForm); - - const emptySelectorFormWithSubmitter = document.createElement('form'); - emptySelectorFormWithSubmitter.action = '/UIHandler/emptySelector'; - document.body.appendChild(emptySelectorFormWithSubmitter); + const naja = mockNaja({ + snippetHandler: SnippetHandler, + uiHandler: UIHandler, + }); + naja.uiHandler.selector = '[data-naja]'; + naja.uiHandler.binding = bindingStrategy; + naja.uiHandler.handler = sinon.spy((_, evt) => evt.preventDefault()); + naja.initialize(); - const emptySelectorSubmitter = document.createElement('input'); - emptySelectorSubmitter.type = 'submit'; - emptySelectorFormWithSubmitter.appendChild(emptySelectorSubmitter); + customSelectorLink.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true})); + assert.isTrue(naja.uiHandler.handler.called); + assert.isTrue(naja.uiHandler.handler.calledWith(customSelectorLink)); - const naja = mockNaja({ - snippetHandler: SnippetHandler, - uiHandler: UIHandler, + document.body.removeChild(customSelectorLink); + cleanUp(naja); }); - naja.uiHandler.selector = ''; - naja.uiHandler.handler = sinon.spy((evt) => evt.preventDefault()); - naja.initialize(); - emptySelectorLink.dispatchEvent(new MouseEvent('click', {cancelable: true})); - emptySelectorForm.dispatchEvent(new SubmitEvent('submit', {cancelable: true})); - emptySelectorFormWithSubmitter.dispatchEvent(new SubmitEvent('submit', {submitter: emptySelectorSubmitter, cancelable: true})); + it('binds to all elements when selector is empty', function () { + const emptySelectorLink = document.createElement('a'); + emptySelectorLink.href = '/UIHandler/emptySelector'; + document.body.appendChild(emptySelectorLink); - assert.equal(naja.uiHandler.handler.callCount, 3); + const emptySelectorForm = document.createElement('form'); + emptySelectorForm.action = '/UIHandler/emptySelector'; + document.body.appendChild(emptySelectorForm); - document.body.removeChild(emptySelectorLink); - document.body.removeChild(emptySelectorForm); - document.body.removeChild(emptySelectorFormWithSubmitter); - }); + const emptySelectorFormWithSubmitter = document.createElement('form'); + emptySelectorFormWithSubmitter.action = '/UIHandler/emptySelector'; + document.body.appendChild(emptySelectorFormWithSubmitter); - it('binds after snippet update', async function () { - const snippetDiv = document.createElement('div'); - snippetDiv.id = 'snippet-uiHandler-snippet-bind'; - document.body.appendChild(snippetDiv); + const emptySelectorSubmitter = document.createElement('input'); + emptySelectorSubmitter.type = 'submit'; + emptySelectorFormWithSubmitter.appendChild(emptySelectorSubmitter); - const naja = mockNaja({ - snippetHandler: SnippetHandler, - uiHandler: UIHandler, + const naja = mockNaja({ + snippetHandler: SnippetHandler, + uiHandler: UIHandler, + }); + naja.uiHandler.selector = ''; + naja.uiHandler.binding = bindingStrategy; + naja.uiHandler.handler = sinon.spy((_, evt) => evt.preventDefault()); + naja.initialize(); + + emptySelectorLink.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true})); + emptySelectorForm.dispatchEvent(new SubmitEvent('submit', {bubbles: true, cancelable: true})); + emptySelectorFormWithSubmitter.dispatchEvent(new SubmitEvent('submit', {submitter: emptySelectorSubmitter, bubbles: true, cancelable: true})); + + assert.equal(naja.uiHandler.handler.callCount, 3); + assert.isTrue(naja.uiHandler.handler.firstCall.calledWith(emptySelectorLink)); + assert.isTrue(naja.uiHandler.handler.secondCall.calledWith(emptySelectorForm)); + assert.isTrue(naja.uiHandler.handler.thirdCall.calledWith(emptySelectorFormWithSubmitter)); + + document.body.removeChild(emptySelectorLink); + document.body.removeChild(emptySelectorForm); + document.body.removeChild(emptySelectorFormWithSubmitter); + cleanUp(naja); }); - naja.uiHandler.handler = sinon.spy((evt) => evt.preventDefault()); - naja.initialize(); - await naja.snippetHandler.updateSnippets({ - 'snippet-uiHandler-snippet-bind': 'test', - }); + it('binds after snippet update', async function () { + const snippetDiv = document.createElement('div'); + snippetDiv.id = 'snippet-uiHandler-snippet-bind'; + document.body.appendChild(snippetDiv); + + const naja = mockNaja({ + snippetHandler: SnippetHandler, + uiHandler: UIHandler, + }); + naja.uiHandler.binding = bindingStrategy; + naja.uiHandler.handler = sinon.spy((_, evt) => evt.preventDefault()); + naja.initialize(); + + await naja.snippetHandler.updateSnippets({ + 'snippet-uiHandler-snippet-bind': 'test', + }); - const a = document.getElementById('uiHandler-snippet-bind'); - a.dispatchEvent(new MouseEvent('click', {cancelable: true})); + const a = document.getElementById('uiHandler-snippet-bind'); + a.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true})); - assert.isTrue(naja.uiHandler.handler.called); - document.body.removeChild(snippetDiv); + assert.isTrue(naja.uiHandler.handler.called); + assert.isTrue(naja.uiHandler.handler.calledWith(a)); + document.body.removeChild(snippetDiv); + cleanUp(naja); + }); }); }); @@ -184,7 +210,7 @@ describe('UIHandler', function () { function simulateInteraction(handler, target, event) { target.addEventListener(event.type, (event) => { event.preventDefault(); - handler.handleUI(event); + handler.handleUI(target, event); }, {once: true}); target.dispatchEvent(event);