Skip to content

Commit

Permalink
chore: add event hooks for default prevention behavior
Browse files Browse the repository at this point in the history
PiperOrigin-RevId: 592375327
  • Loading branch information
asyncLiz authored and copybara-github committed Dec 20, 2023
1 parent eca1357 commit d06a3e7
Show file tree
Hide file tree
Showing 2 changed files with 356 additions and 0 deletions.
176 changes: 176 additions & 0 deletions internal/events/dispatch-hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

/**
* A symbol used to access dispatch hooks on an event.
*/
const dispatchHooks = Symbol('dispatchHooks');

/**
* An `Event` with additional symbols for dispatch hooks.
*/
interface EventWithDispatchHooks extends Event {
[dispatchHooks]: EventTarget;
}

/**
* Add a hook for an event that is called after the event is dispatched and
* propagates to other event listeners.
*
* This is useful for behaviors that need to check if an event is canceled.
*
* The callback is invoked synchronously, which allows for better integration
* with synchronous platform APIs (like `<form>` or `<label>` clicking).
*
* Note: `setupDispatchHooks()` must be called on the element before adding any
* other event listeners. Call it in the constructor of an element or
* controller.
*
* @example
* ```ts
* class MyControl extends LitElement {
* constructor() {
* super();
* setupDispatchHooks(this, 'click');
* this.addEventListener('click', event => {
* afterDispatch(event, () => {
* if (event.defaultPrevented) {
* return
* }
*
* // ... perform logic
* });
* });
* }
* }
* ```
*
* @example
* ```ts
* class MyController implements ReactiveController {
* constructor(host: ReactiveElement) {
* // setupDispatchHooks() may be called multiple times for the same
* // element and events, making it safe for multiple controllers to use it.
* setupDispatchHooks(host, 'click');
* host.addEventListener('click', event => {
* afterDispatch(event, () => {
* if (event.defaultPrevented) {
* return;
* }
*
* // ... perform logic
* });
* });
* }
* }
* ```
*
* @param event The event to add a hook to.
* @param callback A hook that is called after the event finishes dispatching.
*/
export function afterDispatch(event: Event, callback: () => void) {
const hooks = (event as EventWithDispatchHooks)[dispatchHooks];
if (!hooks) {
throw new Error(`'${event.type}' event needs setupDispatchHooks().`);
}

hooks.addEventListener('after', callback);
}

/**
* A lookup map of elements and event types that have a dispatch hook listener
* set up. Used to ensure we don't set up multiple hook listeners on the same
* element for the same event.
*/
const ELEMENT_DISPATCH_HOOK_TYPES = new WeakMap<Element, Set<string>>();

/**
* Sets up an element to add dispatch hooks to given event types. This must be
* called before adding any event listeners that need to use dispatch hooks
* like `afterDispatch()`.
*
* This function is safe to call multiple times with the same element or event
* types. Call it in the constructor of elements, mixins, and controllers to
* ensure it is set up before external listeners.
*
* @example
* ```ts
* class MyControl extends LitElement {
* constructor() {
* super();
* setupDispatchHooks(this, 'click');
* this.addEventListener('click', this.listenerUsingAfterDispatch);
* }
* }
* ```
*
* @param element The element to set up event dispatch hooks for.
* @param eventTypes The event types to add dispatch hooks to.
*/
export function setupDispatchHooks(
element: Element,
...eventTypes: [string, ...string[]]
) {
let typesAlreadySetUp = ELEMENT_DISPATCH_HOOK_TYPES.get(element);
if (!typesAlreadySetUp) {
typesAlreadySetUp = new Set();
ELEMENT_DISPATCH_HOOK_TYPES.set(element, typesAlreadySetUp);
}

for (const eventType of eventTypes) {
// Don't register multiple dispatch hook listeners. A second registration
// would lead to the second listener re-dispatching a re-dispatched event,
// which can cause an infinite loop inside the other one.
if (typesAlreadySetUp.has(eventType)) {
continue;
}

// When we re-dispatch the event, it's going to immediately trigger this
// listener again. Use a flag to ignore it.
let isRedispatching = false;
element.addEventListener(
eventType,
(event: Event) => {
if (isRedispatching) {
return;
}

// Do not let the event propagate to any other listener (not just
// bubbling listeners with `stopPropagation()`).
event.stopImmediatePropagation();
// Make a copy.
const eventCopy = Reflect.construct(event.constructor, [
event.type,
event,
]);

// Add hooks onto the event.
const hooks = new EventTarget();
(eventCopy as EventWithDispatchHooks)[dispatchHooks] = hooks;

// Re-dispatch the event. We can't reuse `redispatchEvent()` since we
// need to add the hooks to the copy before it's dispatched.
isRedispatching = true;
const dispatched = element.dispatchEvent(eventCopy);
isRedispatching = false;
if (!dispatched) {
event.preventDefault();
}

// Synchronously call afterDispatch() hooks.
hooks.dispatchEvent(new Event('after'));
},
{
// Ensure this listener runs before other listeners.
// `setupDispatchHooks()` should be called in constructors to also
// ensure they run before any other externally-added capture listeners.
capture: true,
},
);

typesAlreadySetUp.add(eventType);
}
}
180 changes: 180 additions & 0 deletions internal/events/dispatch-hooks_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

// import 'jasmine'; (google3-only)

import {afterDispatch, setupDispatchHooks} from './dispatch-hooks.js';

describe('dispatch hooks', () => {
let element: HTMLDivElement;

beforeEach(() => {
element = document.createElement('div');
document.body.appendChild(element);
});

afterEach(() => {
document.body.removeChild(element);
});

describe('setupDispatchHooks()', () => {
it('does not add more than one setup listener for an event type', () => {
spyOn(element, 'addEventListener').and.callThrough();
setupDispatchHooks(element, 'foo');
setupDispatchHooks(element, 'foo');

expect(element.addEventListener)
.withContext('element.addEventListener')
.toHaveBeenCalledTimes(1);
});

it('can add setup listeners for multiple event types', () => {
spyOn(element, 'addEventListener').and.callThrough();

setupDispatchHooks(element, 'foo', 'bar', 'baz');
expect(element.addEventListener)
.withContext('element.addEventListener')
.toHaveBeenCalledTimes(3);
});
});

describe('afterDispatch()', () => {
it('resolves synchronously after the event is finished dispatching', () => {
setupDispatchHooks(element, 'click');

const afterDispatchCallback = jasmine.createSpy('afterDispatchCallback');
const clickListener = jasmine
.createSpy('clickListener')
.and.callFake((event: Event) => {
afterDispatch(event, afterDispatchCallback);
});

element.addEventListener('click', clickListener);
element.click();

expect(clickListener)
.withContext('clickListener')
.toHaveBeenCalledTimes(1);
expect(afterDispatchCallback)
.withContext('afterDispatch() callback')
.toHaveBeenCalledTimes(1);
});

it('supports multiple afterDispatch listeners', () => {
setupDispatchHooks(element, 'click');

const firstAfterDispatchCallback = jasmine.createSpy(
'firstAfterDispatchCallback',
);
element.addEventListener('click', (event) => {
afterDispatch(event, firstAfterDispatchCallback);
});

const secondAfterDispatchCallback = jasmine.createSpy(
'secondAfterDispatchCallback',
);
element.addEventListener('click', (event) => {
afterDispatch(event, secondAfterDispatchCallback);
});

element.click();

expect(firstAfterDispatchCallback)
.withContext('afterDispatch() first callback')
.toHaveBeenCalledTimes(1);
expect(secondAfterDispatchCallback)
.withContext('afterDispatch() second callback')
.toHaveBeenCalledTimes(1);
});

it('resolves synchronously after the event is finished dispatching', () => {
setupDispatchHooks(element, 'click');

const afterDispatchCallback = jasmine.createSpy('afterDispatchCallback');
const clickListener = jasmine
.createSpy('clickListener')
.and.callFake((event: Event) => {
afterDispatch(event, afterDispatchCallback);
});

element.addEventListener('click', clickListener);
element.click();

expect(clickListener)
.withContext('clickListener')
.toHaveBeenCalledTimes(1);
expect(afterDispatchCallback)
.withContext('afterDispatch() callback')
.toHaveBeenCalledTimes(1);
});

it('can be used to synchronously detect if event was canceled', () => {
setupDispatchHooks(element, 'click');

// element listener
let eventDefaultPreventedInAfterDispatch: boolean | null = null;
element.addEventListener('click', (event) => {
afterDispatch(event, () => {
eventDefaultPreventedInAfterDispatch = event.defaultPrevented;
});
});

// client listener
element.addEventListener('click', (event) => {
event.preventDefault();
});

element.click();

expect(eventDefaultPreventedInAfterDispatch)
.withContext('event.defaultPrevented() in afterDispatch() callback')
.toBeTrue();
});

it('throws if setupDispatchHooks() was not called for the event type', () => {
// Do not set up hooks
let errorThrown: unknown;
element.addEventListener('click', (event) => {
try {
afterDispatch(event, () => {});
} catch (error) {
errorThrown = error;
}
});

element.click();
expect(errorThrown)
.withContext('error thrown calling afterDispatch()')
.toBeInstanceOf(Error);

expect((errorThrown as Error).message)
.withContext('errorThrown.message')
.toMatch('setupDispatchHooks');
});

it('does not fire multiple times if setupDispatchHooks() is called multiple times for the same element', () => {
setupDispatchHooks(element, 'click');
setupDispatchHooks(element, 'click');

const afterDispatchCallback = jasmine.createSpy('afterDispatchCallback');
const clickListener = jasmine
.createSpy('clickListener')
.and.callFake((event: Event) => {
afterDispatch(event, afterDispatchCallback);
});

element.addEventListener('click', clickListener);
element.click();

expect(clickListener)
.withContext('clickListener')
.toHaveBeenCalledTimes(1);
expect(afterDispatchCallback)
.withContext('afterDispatch() callback')
.toHaveBeenCalledTimes(1);
});
});
});

0 comments on commit d06a3e7

Please sign in to comment.