Skip to content

Commit

Permalink
UIHandler: extract binding strategy to support event delegation
Browse files Browse the repository at this point in the history
  • Loading branch information
jiripudil committed Jan 2, 2025
1 parent f8753e9 commit 6cf79c2
Show file tree
Hide file tree
Showing 3 changed files with 232 additions and 107 deletions.
15 changes: 15 additions & 0 deletions docs/ui-binding.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
148 changes: 116 additions & 32 deletions src/core/UIHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BindingStrategyType, BindingStrategy>;

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);

Check warning on line 34 in src/core/UIHandler.ts

View check run for this annotation

Codecov / codecov/patch

src/core/UIHandler.ts#L34

Added line #L34 was not covered by tests
}

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 = () => {
Expand Down Expand Up @@ -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;
}

Check warning on line 174 in src/core/UIHandler.ts

View check run for this annotation

Codecov / codecov/patch

src/core/UIHandler.ts#L171-L174

Added lines #L171 - L174 were not covered by tests

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;
}

Check warning on line 186 in src/core/UIHandler.ts

View check run for this annotation

Codecov / codecov/patch

src/core/UIHandler.ts#L183-L186

Added lines #L183 - L186 were not covered by tests

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
}

Check warning on line 233 in src/core/UIHandler.ts

View check run for this annotation

Codecov / codecov/patch

src/core/UIHandler.ts#L232-L233

Added lines #L232 - L233 were not covered by tests
}
176 changes: 101 additions & 75 deletions tests/Naja.UIHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,106 +85,132 @@ 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': '<a href="/UIHandler/snippetBind" class="ajax" id="uiHandler-snippet-bind">test</a>',
});
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': '<a href="/UIHandler/snippetBind" class="ajax" id="uiHandler-snippet-bind">test</a>',
});

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);
});
});
});

describe('handleUI()', 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);
Expand Down

0 comments on commit 6cf79c2

Please sign in to comment.